commit e99dfaef4da194a1b7300f0721340c074b977569 Author: SkyLi Date: Fri May 15 14:34:35 2026 +0800 初始化项目 v2.0:中国邮政揽收系统自动填单工具 基于 HTTP API + Node.js token bridge 方案,不依赖浏览器自动化。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..875be68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info +*.spec + +# Virtual environments +.venv/ + +# Node.js +token_bridge/node_modules/ + +# HAR captures (large binary data) +docs/*.har + +# Runtime data +session.json + +# IDE +.vscode/ +.idea/ + +# Claude Code +CLAUDE.md diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/README.md b/README.md new file mode 100644 index 0000000..76392b8 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# 中国邮政揽收系统 - 自动填单工具 + +通过 HTTP API + Node.js token bridge 实现,**不依赖浏览器自动化**。读取 Excel 运单数据,逐条自动填写产品、客户、运单信息并提交。 + +## 运行环境 + +- Windows 10/11 64 位 +- **Node.js 18+**(用于 token bridge,计算 $_ts 安全框架的 c93l5Fya 动态 token) + +## 快速开始 + +```bash +# 安装 Python 依赖 +uv sync + +# 安装 token bridge 依赖 +cd token_bridge && npm install && cd .. + +# 运行 +uv run python main.py +``` + +## 打包 + +```bash +build.bat # Windows +bash build.sh # Linux +``` + +输出 `dist/PickupAuto.exe`,用户端需安装 Node.js 18+。 + +## 架构 + +``` +┌─────────────────────────────────────────┐ +│ Python (requests.Session) │ +│ ├─ engine.py 引擎/表单组装/提交 │ +│ ├─ pickup_api.py HTTP API 封装(18个) │ +│ ├─ login_helper.py cookies 导入 │ +│ ├─ field_mapping.py 中文字段映射 │ +│ ├─ excel_reader.py Excel 读写 │ +│ ├─ main.py Tkinter GUI │ +│ └─ log.py 统一日志 │ +├─────────────────────────────────────────┤ +│ Node.js (HTTP server, 同进程启动) │ +│ └─ token_bridge/server.js │ +│ jsdom 加载 $_ts 安全 JS │ +│ 计算 c93l5Fya token │ +└─────────────────────────────────────────┘ +``` + +### 数据流 + +``` +Excel 行数据 + → 产品搜索 (autocomplete API) → 用户选择 + → 客户搜索 (autocomplete API) → 用户选择 + → 运单查询 → 获取收寄件人/地址/电话 + → 地址匹配 → 获取区划代码 + → 组装表单 (5层优先级) → 确认弹窗 → 提交 +``` + +### 表单字段优先级 + +``` +强制固定值 (force=true) + > API 获取值 (运单/产品/客户/地址匹配) + > 固定值 (force=false) + > Excel 表格值 + > 默认值 (config.json defaults) +``` + +### token bridge 原理 + +揽收系统使用 `$_ts` 安全框架(954KB 混淆 JS),所有 AJAX 请求需附带 `c93l5Fya=TOKEN` 参数。token bridge 在 jsdom 中运行该 JS,通过 HTTP API 对外提供 token 计算服务,Python 拿到 token 后用自己的 `requests.Session` 发请求。 + +## 使用流程 + +### 1. 登录(获取 cookies) + +点击 `🔑 登录` → 弹出对话框 → 用浏览器 cookies 导出工具导出 `10.4.188.1` 的所有 cookies 为 JSON 数组 → 粘贴到对话框 → 保存。 + +cookies 保存到 `session.json`(与 exe 同目录)。 + +### 2. 选择 Excel + +点击 `选择文件`,选择要处理的 Excel 文件。 + +### 3. 配置固定值(可选) + +在 GUI 固定值表格中设置常用字段,勾选 "强制覆盖" 可覆盖 API 获取的值。 + +### 4. 开始处理 + +点击 `▶ 开始处理` → 弹出产品/客户选择框 → 确认提交信息 → 自动批量处理后续行。 + +## Excel 格式 + +| 字段 | 说明 | 来源 | +|------|------|------| +| 单号 | 运单号 | Excel 必填 | +| 重量 | 单位克 | Excel | +| 产品名称 | 用于 autocomplete 搜索 | Excel/固定值 | +| 客户名称 | 用于 autocomplete 搜索 | Excel/固定值 | +| 收件人/手机/地址 | 运单 API 自动填充,Excel 可覆盖 | API/Excel | +| 寄件人/手机/地址 | 客户资料 API 自动填充 | API/固定值 | +| 寄达地/寄达地区划 | 地址匹配 API 自动获取,固定值可强制覆盖 | API/固定值 | +| 电商平台 | 默认 "无"(WU) | 默认值 | +| 付款方式 | 默认 "寄付"(1) | 默认值 | +| 内件属性 | 产品 API 返回 `innerpartsType` | API | +| 运输类型 | 默认 "陆运"(1) | 默认值 | + +## 配置说明 (config.json) + +### system +- `url`: 揽收系统地址 + +### timing +- `delay_between_rows_ms`: 行间延时(毫秒) + +### fixed_values +``` +"字段名": {"value": "值", "force": false} +``` +`force: true` 时覆盖 API 获取值。 + +### defaults +字段的默认值(中文名,自动翻译为系统编码)。 + +### field_mapping +Excel 列名 → 表单参数名的自定义映射。内置映射覆盖常见字段,仅需在此配置自定义列名。 + +## 文档 + +项目级文档见 `../docs/`: + +| 文件 | 说明 | +|------|------| +| `分析报告.md` | 系统 API 分析 | +| `字段覆盖检查报告.md` | 字段来源和覆盖关系 | +| `har-successful-submit-fields.json` | 成功提交的完整表单字段模板 | +| `portal-to-pickup-full-flow.har` | 门户→揽收页面完整网络流 | +| `product-customer-selection-flow.har` | 产品/客户选择 API 流 | +| `successful-submit-with-address-change.har` | 改寄达地后成功提交 | + +## 开发 + +### 日志等级 + +`log.py` 中修改 `LEVEL`: +- `0` = 仅用户级消息 +- `1` = 调试级 +- `2` = 追踪级(API 请求/响应) + +修改后重新打包生效。 diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..51ceb4f --- /dev/null +++ b/build.bat @@ -0,0 +1,94 @@ +@echo off +setlocal enabledelayedexpansion +chcp 65001 >nul 2>&1 + +echo ============================================ +echo PickupAuto Build Script (HTTP mode) +echo ============================================ +echo. + +:: Check Python +python --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo [ERROR] Python not found. Please install Python 3.10+ + echo https://www.python.org/downloads/ + pause + exit /b 1 +) + +:: Check Node.js (for token bridge) +node --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo [ERROR] Node.js not found. Please install Node.js 18+ + echo https://nodejs.org/ + pause + exit /b 1 +) + +:: Check uv, install if missing +uv --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo [INFO] Installing uv... + pip install uv + if %errorlevel% neq 0 ( + echo [ERROR] Failed to install uv + pause + exit /b 1 + ) +) + +echo [1/4] Installing Python dependencies... +uv sync +if %errorlevel% neq 0 ( + echo [ERROR] Python dependency install failed + pause + exit /b 1 +) + +echo [2/4] Installing token bridge dependencies... +cd token_bridge +call npm install +if %errorlevel% neq 0 ( + echo [ERROR] npm install failed in token_bridge + pause + exit /b 1 +) +cd .. + +echo [3/4] Cleaning old build... +if exist dist rmdir /s /q dist +if exist build rmdir /s /q build + +echo [4/4] Building exe with PyInstaller... +uv run pyinstaller ^ + --onefile ^ + --windowed ^ + --name "PickupAuto" ^ + --icon="pickup.ico" ^ + --add-data "config.json;." ^ + --add-data "pickup.ico;." ^ + --add-data "token_bridge;token_bridge" ^ + --hidden-import=openpyxl ^ + --hidden-import=requests ^ + main.py + +if %errorlevel% neq 0 ( + echo [ERROR] PyInstaller build failed + pause + exit /b 1 +) + +:: Copy config to dist for reference +copy config.json dist\config.json >nul 2>&1 + +echo. +echo ============================================ +echo Build complete! +echo Output: dist\PickupAuto.exe +echo. +echo Requirements for end users: +echo - Windows 10/11 64-bit +echo - Node.js 18+ (for security token computation) +echo - Access to internal network (10.4.188.1) +echo ============================================ +pause diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..c9d3467 --- /dev/null +++ b/build.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "============================================" +echo " PickupAuto Build Script (HTTP mode)" +echo "============================================" +echo + +# Check dependencies +command -v python3 >/dev/null 2>&1 || { echo "[ERROR] Python 3.10+ required"; exit 1; } +command -v node >/dev/null 2>&1 || { echo "[ERROR] Node.js 18+ required"; exit 1; } +command -v npm >/dev/null 2>&1 || { echo "[ERROR] npm required"; exit 1; } + +# Check uv +if ! command -v uv &>/dev/null; then + echo "[INFO] Installing uv..." + pip install uv +fi + +echo "[1/4] Installing Python dependencies..." +uv sync + +echo "[2/4] Installing token bridge dependencies..." +cd token_bridge +npm install +cd .. + +echo "[3/4] Cleaning old build..." +rm -rf dist build + +echo "[4/4] Building with PyInstaller..." +uv run pyinstaller \ + --onefile \ + --windowed \ + --name "PickupAuto" \ + --add-data "config.json:." \ + --add-data "pickup.ico:." \ + --add-data "token_bridge:token_bridge" \ + --hidden-import=openpyxl \ + --hidden-import=requests \ + main.py + +cp config.json dist/config.json 2>/dev/null || true + +echo +echo "============================================" +echo " Build complete!" +echo " Output: dist/PickupAuto" +echo +echo " Requirements for end users:" +echo " - Node.js 18+ (for security token computation)" +echo " - Access to internal network (10.4.188.1)" +echo "============================================" diff --git a/config.json b/config.json new file mode 100644 index 0000000..73a7213 --- /dev/null +++ b/config.json @@ -0,0 +1,34 @@ +{ + "system": { + "url": "https://10.4.188.1/pickup-web/a/postwaybill/main" + }, + "timing": { + "delay_between_rows_ms": 3000 + }, + "field_mapping": { + "_comment": "仅需在此配置与内置映射不同的自定义列名,默认使用内置映射" + }, + "fixed_values": { + "客户名称": {"value": "", "force": false}, + "寄件人": {"value": "", "force": false}, + "寄件地址": {"value": "", "force": false}, + "寄达地": {"value": "", "force": false}, + "寄达地区划": {"value": "", "force": false}, + "产品名称": {"value": "", "force": false}, + "内件品名": {"value": "", "force": false} + }, + "defaults": { + "电商平台": "无", + "付款方式": "寄付", + "证件类型": "身份证", + "保价金额": "", + "代收货款": "", + "运输类型": "陆运", + "内件属性": "其他", + "件数": "1", + "保价标志": "不保价", + "回单标志": "无回单", + "代收标志": "不代收", + "投递方式": "普通投递" + } +} \ No newline at end of file diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..5fc05a2 --- /dev/null +++ b/engine.py @@ -0,0 +1,1032 @@ +"""HTTP 自动化引擎 — 通过直接调用后端 API 完成表单填写和提交。 + +不依赖浏览器,使用 Node.js token bridge 计算 $_ts 安全 token。 +""" + +import asyncio +import copy +from datetime import datetime +from typing import Any, Callable + +from log import debug as _ld, trace as _lt, user as _lu +from pickup_api import PickupAPI, PickupSession, TokenBridge +from field_mapping import get_fixed_value_str, translate_value + +# ─── Submit 表单构建器 ───────────────────────────────────────────────── + +# HAR 成功提交的完整字段模板(所有字段初始化为空,逐层覆盖) +_FORM_TEMPLATE: dict[str, str] = { + "QRcodeTransId": "", + "allCargoFileNames": "", + "baoLiuTiHuoDan": "", + "baoLiuTiHuoDanInsuranceAmount": "", + "batteryFlag": "", + "bizProductId": "", + "bizProductName": "", + "bizProductNo": "", + "cargoOptionName": "", + "cargoOptionNo": "", + "cargoPriceNumber": "", + "cargoTypeNameEn": "", + "caseNo": "", + "cha": "", + "chang": "", + "changeMinGanFlag": "", + "changeNoFlag": "", + "codAmount": "", + "codFlag": "", + "consignorIdName": "", + "consignorIdNo": "", + "consignorIdType": "", + "consignorMobile": "", + "consignorOtherFlag": "", + "contentsAttribute": "", + "contentsQuantity": "", + "contentsWeight": "", + "custRecvPhoneIsEmpty": "", + "customerManagerId": "", + "customerManagerName": "", + "customerManagerNo": "", + "deliverDate": "", + "deliverPreDate": "", + "deliverSign": "", + "deliverType": "", + "deliverTypeErjiText": "", + "deliverTypeSource": "", + "discountRate": "", + "ecommerceNo": "", + "fayuanName": "", + "fayuanNo": "", + "fffs": "", + "gao": "", + "goodsDesc": "", + "height": "", + "identifyFlag": "", + "ifInspection": "", + "imClient": "", + "importantAddress": "", + "innerChannel": "", + "insuranceAmount": "", + "insuranceAmountTempHide": "", + "insuranceFlag": "", + "isBreakable": "", + "isForceFillRecevier": "", + "isSpecialMarketing": "", + "isStoredCard": "", + "jdds": "", + "jsxbsx": "", + "ksms": "", + "kuan": "", + "length": "", + "lockCustomer": "", + "logisticsOrderNo": "", + "mailBoxKeyDown": "", + "mdjmbs": "", + "more3": "", + "more4": "", + "njxzs": "", + "noChangeProductXunShi": "", + "oldBizOccurDate": "", + "oldConsignorIdName": "", + "oldConsignorIdNo": "", + "oldConsignorMobile": "", + "omsCodFlag": "", + "omsContentsAttribute": "", + "omsInfoId": "", + "omsInfoReserved35": "", + "omsMinGanFlag": "", + "omsPackageMaterialTypeNo": "", + "omsPaymentMode": "", + "omsReceiverDistrictNo": "", + "omsReceiverMobile": "", + "omsSenderMobile": "", + "omsXunshiFlag": "", + "oneBillFlag": "", + "orderId": "", + "orderWeight": "", + "orgCityCode": "", + "orgProvinceCode": "", + "packageMaterial": "", + "packagehj": "", + "packaging": "", + "patrolProductFlag": "", + "paymentMode": "", + "paymentType": "", + "pickupOrgIntegrate": "", + "pickupPersonName": "", + "pickupPersonNo": "", + "pickupType": "", + "pkpGoodsFlag": "", + "pkpWaybillId": "", + "postBlend": "", + "preWaybillNo": "", + "printFlag": "", + "printtype": "", + "productType": "", + "projectId": "", + "qinQingFlag": "", + "quantity": "", + "readRemotePayMobileFlag": "", + "realNameCheckFlag": "", + "realNameFlag": "", + "realNameNotAdjust": "", + "realNameNotCheck": "", + "realWeight": "", + "reasonForTemporaryStorage": "", + "receiptFeeAmount": "", + "receiptFlag": "", + "receiptWaybillNo": "", + "receiver": "", + "receiverAddr": "", + "receiverAddrAdditional": "", + "receiverArriveOrgName": "", + "receiverDistrictNo": "", + "receiverDuty": "", + "receiverFixtel": "", + "receiverGis": "", + "receiverId": "", + "receiverImId": "", + "receiverImType": "", + "receiverLinker": "", + "receiverMobile": "", + "receiverNo": "", + "receiverNotes": "", + "receiverOrg": "", + "receiverPostcode": "", + "receiverSafetyCode": "", + "receiverType": "", + "receiverWarehouseId": "", + "receiverWarehouseName": "", + "registeredCustomerNo": "", + "remotePayMobile": "", + "reserved10": "", + "reserved12": "", + "reserved19": "", + "reserved29": "", + "reserved31": "", + "reserved4": "", + "reserved45": "", + "reserved5": "", + "reserved9": "", + "reusablePackagingCode": "", + "secretType": "", + "securityProtocol": "", + "securityProtocolTitle": "", + "sender": "", + "senderAddr": "", + "senderAddrAdditional": "", + "senderAgentIdNo": "", + "senderAgentIdType": "", + "senderClientType": "", + "senderDistrictNo": "", + "senderFixtel": "", + "senderGis": "", + "senderId": "", + "senderIdEncryptedCode": "", + "senderIdNo": "", + "senderIdType": "", + "senderImId": "", + "senderImType": "", + "senderIsCheckAddress": "", + "senderLinker": "", + "senderMobile": "", + "senderNo": "", + "senderNotes": "", + "senderOrg": "", + "senderPostcode": "", + "senderRemotePayMobile": "", + "senderSafetyCode": "", + "senderSignType": "", + "senderType": "", + "senders": "", + "settlementMode": "", + "signatureURL": "", + "signingTime": "", + "singleSealFlag": "", + "storedValueCardCheckFlag": "", + "subBillDelFlag": "", + "templateCode": "", + "tiHuoDanHaoTemp": "", + "transId": "", + "transferType": "", + "valuableFlag": "", + "waybillNo": "", + "width": "", + "yangGuangBaoXianOmsQuantity": "", + "yangGuangBaoXianQuantity": "", + "zhudanhao": "", +} + +# 系统固定标志(每次提交都需要,值固定) +_SYSTEM_FLAGS: dict[str, str] = { + "ksms": "0", + "fffs": "0", + "jdds": "0", + "njxzs": "0", + "insuranceFlag": "1", + "receiptFlag": "1", + "codFlag": "9", + "deliverSign": "01", + "quantity": "1", + "reserved29": "0", + "imClient": "0", + "yangGuangBaoXianQuantity": "0", + "yangGuangBaoXianOmsQuantity": "0", + "omsXunshiFlag": "0", + "senderClientType": "2", + "noChangeProductXunShi": "0", + "isForceFillRecevier": "1", + "reserved10": '{"receiverAdderCode":"0","secretType":"1"}', + "realNameCheckFlag": "1", + "pkpGoodsFlag": "1", + "consignorOtherFlag": "0", + "pickupOrgIntegrate": "0", + "secretType": "1", + "senderSignType": "2", + "mdjmbs": "0", + "reserved31": "0", + "innerChannel": "0", + "settlementMode": "3", + "senderType": "1", + "printFlag": "0", + "printtype": "0", + "isSpecialMarketing": "2", + "packagehj": "0", + "more3": "0", + "more4": "0", + "cargoPriceNumber": "0", + "paymentType": "1", + "cha": "1", + "deliverType": "3", + "pkpWaybillId": "", + "reserved4": "0000", + "orderWeight": "1000", +} + + +class PickupEngine: + """揽收系统自动化引擎(HTTP API 模式)。 + + 工作模式: + 1. 启动 token bridge(Node.js 子进程) + 2. Python 管理 HTTP 会话和 cookies + 3. 所有业务操作通过后端 API 直接完成 + 4. 用户通过 choose/confirm 回调参与首批决策 + """ + + def __init__( + self, + url: str, + field_mapping: dict[str, str], + defaults: dict[str, str], + timing: dict[str, Any], + log_callback: Callable[[str], None] = None, + fixed_values: dict[str, str] = None, + choose_callback: Callable = None, + confirm_callback: Callable = None, + ): + self.url = url + self.field_mapping = field_mapping + self.defaults = defaults + self.timing = timing + self._log_cb = log_callback or print + self.fixed_values = fixed_values or {} + self._choose = choose_callback + self._confirm = confirm_callback + + # 单次 batch 缓存(batch 结束后清除) + self._product_choice: dict | None = None + self._customer_choice: dict | None = None + self._first_row_confirmed: bool = False + + # 基础设施 + self._sess: PickupSession | None = None + self._api: PickupAPI | None = None + self._bridge: TokenBridge | None = None + + self._stop_requested = False + self._pause_requested = False + + # 跨行复用的产品/客户详情(从 API 获取) + self._product_detail: dict = {} + self._customer_detail: dict = {} + # 经办人实名信息(证件类型/姓名/证件号/手机,来自 findrealnamehistorybysender API,与客户绑定) + self._sender_realname: dict = {} + # 地址匹配结果 + self._address_match: dict = {} + + def _ulog(self, msg: str) -> None: + _lu(msg) + + def _dlog(self, msg: str) -> None: + _ld(msg) + + # ─── 字段值解析 ────────────────────────────────────────── + + def _get_value(self, row: dict[str, str], excel_col: str) -> str: + """优先级:固定值 > Excel 表格值 > 默认值。中文值自动翻译为系统编码。""" + fixed = get_fixed_value_str(self.fixed_values, excel_col) + if fixed: + return translate_value(excel_col, fixed) + value = row.get(excel_col, "").strip() + if value: + return translate_value(excel_col, value) + return translate_value(excel_col, self.defaults.get(excel_col, "")) + + # ─── 生命周期 ──────────────────────────────────────────── + + async def start(self) -> None: + """启动 token bridge 并初始化 HTTP 会话。""" + self._ulog("启动 HTTP API 引擎...") + + # 启动 token bridge + self._bridge = TokenBridge() + self._bridge.start() + self._dlog("Token bridge 已启动") + + # 创建 HTTP 会话 + self._sess = PickupSession(self.url) + + # 尝试加载已保存的 cookies + session_path = self._get_session_path() + if self._sess.load_session(session_path): + cookie_names = {c.name for c in self._sess._http.cookies} + missing = [n for n in ("JSESSIONID", "jdpt.session.id") if n not in cookie_names] + if missing: + self._ulog(f"缺少关键 cookie: {', '.join(missing)},请重新登录并手动填写") + else: + self._dlog("已加载登录会话") + else: + self._ulog("未找到登录会话,请先登录") + + async def begin_processing(self) -> None: + """加载 pickup 页面,初始化 token bridge。""" + if not self._sess: + raise RuntimeError("未启动") + + self._ulog("加载页面资源...") + resources = self._sess.fetch_page_resources() + + # 构造最小 HTML:只设置 $_ts.cd/nsd,不包含任何会阻塞 jsdom 的内联脚本 + minimal_html = ( + f"" + ) + self._dlog(f"初始化 token bridge (cd={resources['ts_cd'][:20]}..., " + f"security JS: {len(resources['scripts'])} files)") + self._bridge.initialize( + minimal_html, + resources["scripts"], + resources["base_url"], + ) + self._dlog("Token bridge 已初始化,$_ts 安全框架就绪") + + # 创建 API 客户端 + self._api = PickupAPI(self._sess, self._bridge) + + # 加载初始数据(电商列表、投递日期等) + try: + self._dlog("预加载: query_ecommerce...") + self._api.query_ecommerce("B") + self._dlog("预加载: query_deliver_date...") + self._api.query_deliver_date() + self._dlog("预加载: check_sheet_warning...") + self._api.check_sheet_warning() + self._dlog("预加载完成") + except Exception as e: + self._dlog(f" [warn] 预加载部分失败: {e}") + + self._ulog("后台自动化已就绪") + + async def stop(self) -> None: + if self._stop_requested: + return # 防止重复调用 + self._stop_requested = True + + # 保存 session cookies + if self._sess: + try: + self._sess.save_session(self._get_session_path()) + self._dlog("Session 已保存") + except Exception: + pass + + # 停止 token bridge + if self._bridge: + try: + self._bridge.stop() + except Exception: + pass + self._bridge = None + self._api = None + + self._ulog("引擎已停止") + # 清除 batch 缓存 + self._clear_batch_cache() + + def _get_session_path(self) -> str: + import os as _os, sys as _sys + if getattr(_sys, "frozen", False): + base = _os.path.dirname(_sys.executable) + else: + base = _os.path.dirname(_os.path.abspath(__file__)) + return _os.path.join(base, "session.json") + + def _clear_batch_cache(self): + self._product_choice = None + self._customer_choice = None + self._product_detail = {} + self._customer_detail = {} + self._sender_realname = {} + self._first_row_confirmed = False + + # ─── 暂停控制 ──────────────────────────────────────────── + + def request_stop(self) -> None: + self._stop_requested = True + + def request_pause(self) -> None: + self._pause_requested = True + + def resume(self) -> None: + self._pause_requested = False + + async def _check_pause(self) -> None: + while self._pause_requested and not self._stop_requested: + await asyncio.sleep(0.5) + + # ─── 单行处理(核心流程)───────────────────────────────── + + async def process_single_row(self, row: dict[str, str]) -> dict: + start_time = datetime.now() + result = {"status": "失败", "message": "", "postage": "", "time": "", "寄达地": ""} + + try: + await self._check_pause() + + # 1. 产品选择 + self._dlog(" 选择产品...") + await self._handle_product(self._get_value(row, "产品名称")) + if self._stop_requested: + return result + + await self._check_pause() + # 2. 客户选择 + self._dlog(" 选择客户...") + await self._handle_customer(self._get_value(row, "客户名称")) + if self._stop_requested: + return result + + await self._check_pause() + # 3. 查询运单 + self._dlog(" 查询运单...") + await self._handle_waybill(row) + if self._stop_requested: + return result + + await self._check_pause() + # 4. 收件地址 + self._dlog(" 匹配收件地址...") + await self._handle_address(row) + + await self._check_pause() + # 5. 货物信息 + self._dlog(" 校验货物...") + await self._handle_cargo_checks(row) + + # 6. 组装表单 + form_data = self._assemble_form(row) + + # 7. 首行确认 + if not self._first_row_confirmed and self._confirm: + if not await self._confirm(form_data): + self._stop_requested = True + result.update(status="取消", message="用户取消确认,已停止", + time=datetime.now().strftime("%H:%M:%S")) + self._ulog(" STOP: 用户取消确认") + return result + self._first_row_confirmed = True + + await self._check_pause() + # 8. 提交 + self._dlog(" 提交中...") + submit_result = await self._submit_form(form_data) + result["寄达地"] = form_data.get("receiverArriveOrgName", "") + + elapsed = (datetime.now() - start_time).total_seconds() + if submit_result.get("success"): + result.update(status="成功", message=submit_result.get("message", ""), + postage=str(submit_result.get("postage", "")), + time=datetime.now().strftime("%H:%M:%S")) + self._ulog(f" OK ({elapsed:.1f}s): {result['message']}") + else: + result.update(status="失败", message=submit_result.get("message", "提交失败"), + time=datetime.now().strftime("%H:%M:%S")) + self._ulog(f" FAIL ({elapsed:.1f}s): {result['message']}") + + except Exception as e: + elapsed = (datetime.now() - start_time).total_seconds() + result.update(status="异常", message=str(e), + time=datetime.now().strftime("%H:%M:%S")) + self._ulog(f" ERROR ({elapsed:.1f}s): {e}") + + return result + + # ─── 产品处理 ──────────────────────────────────────────── + + async def _handle_product(self, product_name: str) -> None: + if not product_name: + return + + if self._product_choice is not None: + biz_id = self._product_choice.get("productId", "") + biz_no = self._product_choice.get("bizProductNo", "") + if biz_id and biz_no: + self._product_detail = self._api.select_product(biz_id, biz_no) + return + + # 首次选择:搜索 → 让用户选 → 缓存 + candidates = self._api.query_product(product_name) + self._dlog(f" query_product('{product_name}') → {len(candidates)} candidates: " + f"{[c.get('productName','')[:20] for c in candidates[:5]]}") + if not candidates: + self._ulog(f" [warn] 产品 '{product_name}' 无候选结果,请在固定值中设置正确的产品名称") + self.request_stop() + return + + if self._choose: + choice = await self._choose( + "选择产品", + [c.get("productName", c.get("name", str(c))) for c in candidates], + ) + if choice is None: + self._ulog("用户取消产品选择,停止 batch") + self.request_stop() + return + if 0 <= choice < len(candidates): + self._product_choice = candidates[choice] + + if not self._product_choice and candidates: + self._product_choice = candidates[0] + + chosen = self._product_choice + if chosen: + biz_id = chosen.get("productId", "") + biz_no = chosen.get("bizProductNo", "") + if biz_no and biz_id: + self._product_detail = self._api.select_product(biz_id, biz_no) + + # ─── 客户处理 ──────────────────────────────────────────── + + async def _handle_customer(self, customer_name: str) -> None: + if not customer_name: + return + + if self._customer_choice is not None: + cust_id = self._customer_choice.get("customerId", "") + if cust_id: + self._customer_detail = self._api.select_customer(str(cust_id)) + if not isinstance(self._customer_detail, dict) or "_text" in self._customer_detail: + self._dlog(f" [warn] customerins 返回异常: {str(self._customer_detail)[:200]}") + return + base = self._customer_detail.get("baseCustome", {}) if isinstance(self._customer_detail.get("baseCustome"), dict) else {} + sender_no = base.get("businessCode") or base.get("customerSubCode") or "" + if sender_no: + try: + self._sender_realname = self._api.find_sender_history( + str(sender_no) + ) + except Exception: + pass + return + + # 首次选择 + candidates = self._api.query_customer(customer_name) + self._dlog(f" query_customer('{customer_name}') → {len(candidates)} candidates: " + f"{[c.get('customerName','')[:20] for c in candidates[:5]]}") + if not candidates: + self._ulog(f" [warn] 客户 '{customer_name}' 无候选结果,请在固定值中设置正确的客户名称") + self.request_stop() + return + + if self._choose: + choice = await self._choose( + "选择客户", + [c.get("customerName", c.get("name", str(c))) for c in candidates], + ) + if choice is None: + self._ulog("用户取消客户选择,停止 batch") + self.request_stop() + return + if 0 <= choice < len(candidates): + self._customer_choice = candidates[choice] + + if not self._customer_choice and candidates: + self._customer_choice = candidates[0] + + chosen = self._customer_choice + if chosen: + cust_id = chosen.get("customerId", "") + if cust_id: + self._customer_detail = self._api.select_customer(str(cust_id)) + if not isinstance(self._customer_detail, dict) or "_text" in self._customer_detail: + self._dlog(f" [warn] customerins 返回异常: {str(self._customer_detail)[:200]}") + return + base = self._customer_detail.get("baseCustome", {}) if isinstance(self._customer_detail.get("baseCustome"), dict) else {} + sender_no = base.get("businessCode") or base.get("customerSubCode") or "" + if sender_no: + try: + self._sender_realname = self._api.find_sender_history( + str(sender_no) + ) + except Exception: + pass + + # ─── 运单查询 ──────────────────────────────────────────── + + async def _handle_waybill(self, row: dict[str, str]) -> None: + waybill_no = self._get_value(row, "单号") + ecommerce = self._get_value(row, "电商平台") + payment = self._get_value(row, "付款方式") + + biz_product_no = ( + self._product_choice.get("bizProductNo", "") + if self._product_choice else "" + ) + biz_product_id = self._product_detail.get("bizProductId", "") + + result = self._api.query_waybill( + waybill_no=waybill_no, + ecommerce_no=ecommerce, + biz_product_no=biz_product_no, + payment_mode=payment, + biz_product_id=str(biz_product_id), + ) + self._waybill_result = result + if isinstance(result.get("data"), dict): + wd = result["data"] + key_vals = {k: wd[k] for k in ["senderLinker","senderMobile","receiverLinker","receiverMobile","receiverAddr","senderAddr","senderNo","internalInfoId","bizProductNo","bizProductName"] if wd.get(k)} + self._dlog(f" waybill key_vals: {key_vals}") + else: + self._dlog(f" waybill result top: {list(result.keys())[:5]}, data type: {type(result.get('data'))}") + + # ─── 地址处理 ──────────────────────────────────────────── + + async def _handle_address(self, row: dict[str, str]) -> None: + receiver_addr = self._get_value(row, "收件地址") + if not receiver_addr: + return + + # 1. 用实际地址查询区划代码(addressMatch API) + dist_code = "" + try: + match = self._api.address_match_raw(receiver_addr) + if isinstance(match, dict): + dist_code = match.get("districtCode", "") or match.get("contyCode", "") + if not dist_code: + dist_code = match.get("provCode", "") + self._address_match = match + self._dlog(f" addressMatch → dist_code={dist_code}") + except Exception: + self._address_match = {} + self._dlog(" [warn] addressMatch 失败") + + # 如果 addressMatch 没结果,尝试用运单数据中的区划代码 + if not dist_code: + if isinstance(self._waybill_result, dict): + wr_data = self._waybill_result.get("data", {}) + if isinstance(wr_data, str): + try: + import json as _json + wr_data = _json.loads(wr_data) + except Exception: + wr_data = {} + if isinstance(wr_data, dict): + dist_code = wr_data.get("receiverDistrictNo", "") + + if dist_code: + try: + self._api.query_district(dist_code) + except Exception: + self._dlog(" [warn] 区划查询失败") + + # 2. 地址网格匹配 + biz_product_no = ( + self._product_choice.get("bizProductNo", "") + if self._product_choice else "" + ) + waybill_no = self._get_value(row, "单号") + sender_no = "" + if self._customer_detail: + sender_no = (self._customer_detail.get("baseCustome", {})).get("businessCode") or "" + + transfer_type = self._get_value(row, "运输类型") + contents_attr = self._get_value(row, "内件属性") + + try: + grid_result = self._api.match_address( + receiver_arrive_org_no=dist_code, + base_product_no=biz_product_no, + waybill_no=waybill_no, + receiver_addr=receiver_addr, + sender_no=sender_no, + transfer_type=transfer_type, + contents_attribute=contents_attr, + ) + self._grid_result = grid_result + except Exception as e: + self._dlog(f" [warn] 地址匹配失败: {e}") + self._grid_result = {} + + # ─── 货物校验 ──────────────────────────────────────────── + + async def _handle_cargo_checks(self, row: dict[str, str]) -> None: + cargo_names = self._get_value(row, "内件品名") + waybill_no = self._get_value(row, "单号") + weight = self._get_value(row, "重量") + + biz_product_no = ( + self._product_choice.get("bizProductNo", "") + if self._product_choice else "" + ) + + # 寄达区划代码 + dist_code = "" + if hasattr(self, '_grid_result') and isinstance(self._grid_result, dict): + dist_code = self._grid_result.get("receiverDistrictNo", dist_code) + + # 客户代码 + cust_code = "" + if self._customer_detail: + cust_code = "" + if isinstance(self._customer_detail, dict): + base = self._customer_detail.get("baseCustome", {}) + if isinstance(base, dict): + cust_code = base.get("businessCode") or "" + + # 校验产品物料 + try: + self._api.check_product_material(cargo_names, waybill_no, biz_product_no, dist_code) + except Exception: + pass + + # 校验可达区域 + try: + self._api.check_reach_area(biz_product_no, dist_code, cust_code, weight, waybill_no) + except Exception: + pass + + # ─── 表单提交 ──────────────────────────────────────────── + + def _assemble_form(self, row: dict[str, str]) -> dict[str, str]: + """组装完整表单数据。优先级:强制固定值 > API > 固定值 > 表格 > 默认值。""" + form = dict(_FORM_TEMPLATE) + form.update(_SYSTEM_FLAGS) + # ── 第一层:默认值 ── + for col, param in self.field_mapping.items(): + val = self.defaults.get(col, "") + if val: + form[param] = translate_value(col, val) + + # ── 第二层:表格值 ── + for col, param in self.field_mapping.items(): + val = row.get(col, "").strip() + if val: + form[param] = translate_value(col, val) + waybill_no = row.get("单号", "").strip() + if waybill_no: + form["logisticsOrderNo"] = waybill_no + + # ── 第三层:固定值(非强制)── + for col_name, cfg in self.fixed_values.items(): + if not cfg.get("force") and cfg.get("value"): + param = self.field_mapping.get(col_name, col_name) + form[param] = translate_value(col_name, cfg["value"]) + + # ── 第四层:API 获取值 ── + if self._product_choice: + form["bizProductNo"] = self._product_choice.get("bizProductNo", "") + form["bizProductName"] = self._product_choice.get("productName", "") + if self._product_detail: + for k in ("bizProductId", "templateCode"): + if self._product_detail.get(k): + form[k] = str(self._product_detail[k]) + # 产品的内件性质(queryproductbymanager 返回 innerpartsType) + ipt = self._product_detail.get("innerpartsType", "") + if ipt: + form["contentsAttribute"] = str(ipt) + + if self._customer_choice: + # senders 用全名(带前缀如 qxsm-穹诩商贸),sender 用短名 + full_name = self._customer_choice.get("customerFullName", "") + short_name = self._customer_choice.get("customerName", "") + if full_name: + form["senders"] = full_name + if short_name: + form["sender"] = short_name + if isinstance(self._customer_detail, dict): + base = self._customer_detail.get("baseCustome", {}) if isinstance(self._customer_detail.get("baseCustome"), dict) else {} + cn = base.get("customerNo", "") + if cn: + form["senderNo"] = str(cn) + cid = base.get("id") or self._customer_detail.get("customerId", "") + if cid: + form["senderId"] = str(cid) + + wr = self._waybill_result if hasattr(self, '_waybill_result') and isinstance(self._waybill_result, dict) else {} + data_raw = wr.get("data", {}) + if isinstance(data_raw, str): + try: + import json as _json + wr_data = _json.loads(data_raw) + self._dlog(f" waybill data parsed from JSON string, keys={len(wr_data)}") + except Exception as e: + self._dlog(f" waybill data JSON parse failed: {e}, raw={data_raw[:100]}") + wr_data = {} + elif isinstance(data_raw, dict): + wr_data = data_raw + else: + wr_data = {} + if not wr_data: + self._ulog(f" [warn] 运单 data 为空,可能 ecommerceNo 不正确,当前值: {self._get_value(row, '电商平台')}") + # 运单中有值的字段填入(白名单:HAR 确认需要的字段) + wr_whitelist = { + "ecommerceNo", + "senderLinker", "senderMobile", "senderPostcode", + "senderDistrictNo", "senderFixtel", + "receiverLinker", "receiverMobile", "receiverPostcode", + "receiverDistrictNo", "receiverArriveOrgNo", "receiverArriveOrgName", + "receiverSafetyCode", + "internalInfoId", + "contentsAttribute", "transferType", + "quantity", "deliverType", "reserved10", + "senderProvinceName", "senderCityName", "senderCountyName", + "receiverProvinceName", "receiverCityName", "receiverCountyName", + } + for key in wr_whitelist: + val = wr_data.get(key) + if val is not None and val != "": + form[key] = str(val) + # 拼接完整地址:省+市+县+地址 + sp = wr_data.get("senderProvinceName", "") + sc = wr_data.get("senderCityName", "") + sn = wr_data.get("senderCountyName", "") + sa = wr_data.get("senderAddr", "") + full_sender = str(sp or "") + str(sc or "") + str(sn or "") + str(sa or "") + if full_sender.strip(): + form["senderAddr"] = full_sender.strip() + rp = wr_data.get("receiverProvinceName", "") + rc = wr_data.get("receiverCityName", "") + rn = wr_data.get("receiverCountyName", "") + ra = wr_data.get("receiverAddr", "") + full_receiver = str(rp or "") + str(rc or "") + str(rn or "") + str(ra or "") + if full_receiver.strip(): + form["receiverAddr"] = full_receiver.strip() + # receiverAddrAdditional 也拼接省市区 + if wr_data.get("receiverAddrAdditional"): + full_radd = str(rp or "") + str(rc or "") + str(rn or "") + str(wr_data["receiverAddrAdditional"]) + form["receiverAddrAdditional"] = full_radd.strip() + for key in wr_whitelist: + val = wr_data.get(key) + if val is not None and val != "": + form[key] = str(val) + if wr_data.get("internalInfoId"): + form["pkpWaybillId"] = str(wr_data["internalInfoId"]) + # 寄达地:用 city+county 拼接 + city = wr_data.get("receiverCityName", "") + county = wr_data.get("receiverCountyName", "") + if city or county: + form["receiverArriveOrgName"] = str(city) + str(county) + # 省份/城市代码 + sp = wr_data.get("senderProvinceNo", "") + sc = wr_data.get("senderCityNo", "") + if sp: + form["orgProvinceCode"] = sp[:2] + if sc: + form["orgCityCode"] = sc[:4] + + # 地址匹配结果(addressMatch API)的区划代码 + if hasattr(self, '_address_match') and isinstance(self._address_match, dict): + dc = self._address_match.get("districtCode") or self._address_match.get("contyCode") or "" + if dc: + form["receiverDistrictNo"] = str(dc) + # receiverArriveOrgName 用 prov+city+county 拼接 + parts = [self._address_match.get(k, "") for k in ("provName", "cityName", "contyName")] + org_name = "".join(p for p in parts if p) + if org_name: + form["receiverArriveOrgName"] = org_name + + if hasattr(self, '_grid_result') and isinstance(self._grid_result, dict): + for k in ("receiverArriveOrgName", "receiverDistrictNo"): + if self._grid_result.get(k): + form[k] = str(self._grid_result[k]) + + # senderNo 使用 businessCode/customerSubCode(不是 customerNo) + if self._customer_detail and isinstance(self._customer_detail, dict): + base = self._customer_detail.get("baseCustome", {}) + if isinstance(base, dict): + sn = base.get("businessCode") or base.get("customerSubCode") or "" + if sn: + form["senderNo"] = str(sn) + # customerManager + for data_k, form_k in [ + ("accountMgrName", "customerManagerName"), + ("accountMgrCode", "customerManagerNo"), + ("id", "customerManagerId"), + ]: + v = base.get(data_k) + if v: + form[form_k] = str(v) + # 寄件人地址(客户资料中的完整地址) + cust_addr = base.get("addressStreet", "") + if cust_addr: + form["senderAddr"] = str(cust_addr) + # 寄件人联系方式 + if base.get("contactName"): + form.setdefault("senderLinker", str(base["contactName"])) + if base.get("contactPhone"): + form.setdefault("senderMobile", str(base["contactPhone"])) + + # 实名信息(在 detail[0] 下) + if isinstance(self._sender_realname, dict): + detail0 = self._sender_realname.get("detail", [{}]) + sr = detail0[0] if isinstance(detail0, list) and detail0 else {} + for sr_key, form_key in [ + ("consignorIdType", "consignorIdType"), + ("consignorIdName", "consignorIdName"), + ("consignorIdNo", "consignorIdNo"), + ("consignorMobile", "consignorMobile"), + ("consignorIdName", "oldConsignorIdName"), + ("consignorIdNo", "oldConsignorIdNo"), + ("consignorMobile", "oldConsignorMobile"), + ]: + if sr.get(sr_key): + form[form_key] = str(sr[sr_key]) + + # ── 第五层(最高):强制覆盖固定值 ── + for col_name, cfg in self.fixed_values.items(): + if cfg.get("force") and cfg.get("value"): + param = self.field_mapping.get(col_name, col_name) + form[param] = translate_value(col_name, cfg["value"]) + + # ── 衍生字段(setdefault 不覆盖 API 值)── + form.setdefault("receiverAddrAdditional", form.get("receiverAddr", "")) + if form.get("receiverMobile"): + form.setdefault("omsReceiverMobile", form["receiverMobile"]) + if form.get("senderMobile"): + form.setdefault("senderFixtel", form["senderMobile"]) + form.setdefault("senderSafetyCode", form["senderMobile"]) + form.setdefault("omsSenderMobile", form["senderMobile"]) + if form.get("senderNotes", ""): + pass # already set above + elif row.get("备注", "").strip(): + form["senderNotes"] = row["备注"].strip() + + return form + + async def _submit_form(self, form_data: dict[str, str]) -> dict: + """提交已组装好的表单数据。""" + if not self._api: + return {"success": False, "message": "API 未初始化"} + # 输出有值的表单字段用于对比 HAR + non_empty = {k: v for k, v in sorted(form_data.items()) if v} + self._dlog(f" submit fields ({len(non_empty)}): {non_empty}") + response = self._api.submit(form_data) + res_mess = response.get("resMess", "") + comment = response.get("comment", "") + return { + "success": "收寄成功" in res_mess, + "message": res_mess, + "postage": str(comment) if comment else "", + } + + # ─── 批量处理 ──────────────────────────────────────────── + + async def process_all( + self, + rows: list[dict[str, str]], + progress_callback: Callable[[int, int], None] = None, + ) -> list[dict]: + results = [] + total = len(rows) + delay = self.timing.get("delay_between_rows_ms", 3000) / 1000 + + try: + for i, row in enumerate(rows): + if self._stop_requested: + self._ulog("用户停止") + break + self._ulog(f"\n[{i+1}/{total}] 运单号: {row.get('运单号', 'N/A')}") + results.append(await self.process_single_row(row)) + if progress_callback: + progress_callback(i + 1, total) + if i < total - 1 and not self._stop_requested: + await asyncio.sleep(delay) + finally: + # batch 结束后清除缓存,下次重新选择 + self._clear_batch_cache() + + return results diff --git a/excel_reader.py b/excel_reader.py new file mode 100644 index 0000000..91039b6 --- /dev/null +++ b/excel_reader.py @@ -0,0 +1,83 @@ +"""Excel 读取模块 — 读取输入 Excel 并写入结果 Excel。""" + +import openpyxl + + +def read_input_excel(file_path: str) -> tuple[list[dict], list[str]]: + """读取输入 Excel 文件,返回 (行数据列表, 列名列表)。 + + 每行数据为 {列名: 值} 的字典。跳过全空行。 + """ + wb = openpyxl.load_workbook(file_path, read_only=True) + ws = wb.active + + rows = list(ws.iter_rows(min_row=1, values_only=True)) + if not rows: + raise ValueError("Excel 文件为空") + + headers = [str(h).strip() if h is not None else "" for h in rows[0]] + if not any(headers): + raise ValueError("未找到表头行") + + data = [] + for row in rows[1:]: + values = [str(c).strip() if c is not None else "" for c in row] + if not any(values): # 跳过全空行 + continue + row_dict = {} + for i, header in enumerate(headers): + if i < len(values): + row_dict[header] = values[i] + else: + row_dict[header] = "" + data.append(row_dict) + + wb.close() + return data, headers + + +def validate_required_columns( + headers: list[str], + fixed_values: dict[str, str] = None, + defaults: dict[str, str] = None, +) -> list[str]: + """验证必要字段是否有数据来源(固定值 > Excel > API > 默认值)。 + + 数据来源优先级: + 1. 固定值设置(客户名称、寄件人、寄件地址、产品名称、寄达地) + 2. Excel 表格(运单号、重量、内件品名) + 3. API 自动获取(querypostwaybillmanager 返回收件人/电话/地址等全部运单信息) + 4. 默认值(电商平台、付款方式等系统编码) + + 只在所有来源都没有值时警告。 + """ + fixed_values = fixed_values or {} + defaults = defaults or {} + + # querypostwaybillmanager API 自动返回的字段,Excel 无需填写 + api_fields = [ + "收件人", "收件人手机", "收件地址", "收件邮编", "收件人固话", + "寄件人", "寄件人手机", "寄件地址", "寄件邮编", + ] + + # 其余需要人工提供数据的字段 + check_fields = [ + "单号", # Excel 必填(API 不返回) + "重量", # Excel 必填(API 返回 orderWeight 但可能为 0) + "产品名称", # 固定值 + "客户名称", # 固定值 + ] + + from field_mapping import get_fixed_value_str + + missing = [] + for field in check_fields: + if get_fixed_value_str(fixed_values, field): + continue + if field in headers: + continue + if defaults.get(field, ""): + continue + missing.append(field) + + return missing diff --git a/field_mapping.py b/field_mapping.py new file mode 100644 index 0000000..8427a18 --- /dev/null +++ b/field_mapping.py @@ -0,0 +1,225 @@ +"""字段映射模块 — 将 Excel 列名映射到页面 DOM 元素 ID。 + +所有中文名→DOM ID 的对应关系写死在 BUILTIN_FIELD_MAP 中。 +config.json 的 field_mapping 用于追加/覆盖自定义映射。 +""" + +import json +from pathlib import Path +from typing import Any + +# 内置字段映射:中文名 → 页面 DOM 元素 ID(写死在代码中) +BUILTIN_FIELD_MAP: dict[str, str] = { + # ── 业务字段 ── + "单号": "waybillNo", + "运单号": "waybillNo", + "产品名称": "bizProductName", + "客户名称": "senders", + # ── 收件人 ── + "收件人": "receiverLinker", + "收件人手机": "receiverMobile", + "收件地址": "receiverAddr", + "收件邮编": "receiverPostcode", + "收件人固话": "receiverFixtel", + # ── 寄件人 ── + "寄件人": "senderLinker", + "寄件人手机": "senderMobile", + "寄件地址": "senderAddr", + "寄件邮编": "senderPostcode", + "寄件人固话": "senderFixtel", + # ── 货物 ── + "重量": "realWeight", + "内件品名": "reserved5", + # ── 电商 / 支付 ── + "电商平台": "ecommerceNo", + "付款方式": "paymentMode", + "证件类型": "consignorIdType", + "保价金额": "insuranceAmount", + "代收货款": "codAmount", + # ── 系统保留字段(通常不需要在 Excel 中填写,只用默认值)── + "运输类型": "transferType", + "内件属性": "contentsAttribute", + "件数": "quantity", + "保价标志": "insuranceFlag", + "回单标志": "receiptFlag", + "代收标志": "codFlag", + "投递方式": "deliverType", + # ── 其他 ── + "寄达地": "receiverArriveOrgName", + "寄达地区划": "receiverDistrictNo", + "备注": "senderNotes", +} + + +def load_config(config_path: str = "config.json") -> dict[str, Any]: + """加载配置文件。""" + # 支持开发环境和 PyInstaller 打包后的路径 + paths_to_try = [ + Path(config_path), + Path(__file__).parent / config_path, + Path.cwd() / config_path, + ] + # PyInstaller _MEIPASS + import sys + if getattr(sys, "frozen", False): + paths_to_try.insert(0, Path(sys._MEIPASS) / config_path) + + for p in paths_to_try: + if p.exists(): + with open(p, "r", encoding="utf-8") as f: + return json.load(f) + + raise FileNotFoundError(f"配置文件未找到: {config_path}") + + +def get_field_mapping(config: dict) -> dict[str, str]: + """获取 Excel 列名 → DOM ID 的映射(内置 + 配置覆盖)。""" + mapping = dict(BUILTIN_FIELD_MAP) + mapping.update(config.get("field_mapping", {})) + return mapping + + +def get_defaults(config: dict) -> dict[str, str]: + """获取默认值(中文键 → 值)。""" + return config.get("defaults", {}) + + +def get_system_config(config: dict) -> dict[str, Any]: + """获取系统配置。""" + return config.get("system", {}) + + +def get_timing_config(config: dict) -> dict[str, Any]: + """获取延时配置。""" + return config.get("timing", {}) + + +def get_fixed_values(config: dict) -> dict[str, dict]: + """获取固定值设置,返回 {字段名: {value, force}}。""" + return config.get("fixed_values", {}) + + +def get_fixed_value_str(fixed_values: dict[str, dict], field: str) -> str: + """从固定值配置中提取字段的字符串值。""" + return fixed_values.get(field, {}).get("value", "") + + +# 中文显示名 → 系统内部编码(用于默认值/固定值的翻译) +# 数据来源:data.har 抓包分析 +CODE_MAP: dict[str, dict[str, str]] = { + "电商平台": { + "无": "WU", + "菜鸟电子面单": "TAOBAOWAYBILL", + "淘宝": "TAOBAO", + "拼多多": "PINDUODUO", + "电子面单": "DZMD", + "大客户订单接口": "DKH", + "云打印": "CLOUD", + "批量导入": "IMPO", + "客户端": "EMSRM", + "公安部": "GAB", + "电子返单": "DZFD", + "仓库配送": "CKPS", + "无": "WU", + }, + "付款方式": { + "寄付": "1", + "到付": "2", + }, + "证件类型": { + "身份证": "01", + }, + "运输类型": { + "陆运": "1", + }, + "内件属性": { + "其他": "00", + "身份证": "01", + "录取通知书": "02", + "法院专递": "03", + "护照": "04", + "通行证": "05", + "签证": "06", + "驾驶证": "07", + "保险文书": "08", + "银行票据": "09", + "行驶证": "10", + "交通违章通知书": "11", + "纳税申报": "12", + "到货通知书": "13", + "公证书": "14", + "学生档案": "15", + "准考证": "16", + "成绩单": "17", + "药监批件": "18", + "移动配送": "19", + "劳动仲裁": "20", + "合格证": "21", + "健康证": "22", + "火车票": "23", + "房证": "24", + "企业代码证": "25", + "教材": "26", + "福利彩票": "27", + "病历": "28", + "银企对账单": "29", + "学位证书": "30", + "车辆档案": "31", + "交房通知书": "32", + "车牌": "36", + "信用卡": "41", + "检察专递": "53", + "法院专递回执": "54", + }, + "保价标志": { + "不保价": "1", + }, + "回单标志": { + "无回单": "1", + }, + "代收标志": { + "不代收": "9", + }, + "投递方式": { + "普通投递": "3", + }, +} + + +def translate_value(excel_col: str, chinese_value: str) -> str: + """将中文显示名翻译为系统内部编码(如 "寄付" → "1")。 + + 如果该字段没有翻译表,或值已经是编码格式,直接返回原值。 + """ + if not chinese_value: + return chinese_value + mapping = CODE_MAP.get(excel_col, {}) + return mapping.get(chinese_value, chinese_value) + + +def reverse_translate(excel_col: str, code_value: str) -> str: + """将系统编码翻译回中文显示名(如 "1" → "寄付")。""" + if not code_value: + return code_value + mapping = CODE_MAP.get(excel_col, {}) + for chinese, code in mapping.items(): + if code == code_value: + return chinese + return code_value + + +def resolve_field_value( + row: dict[str, str], + excel_col: str, + defaults: dict[str, str], + fixed_values: dict[str, str] = None, +) -> str: + """解析字段值,优先级:固定值 > Excel > 默认值。结果自动翻译为系统编码。""" + fixed_values = fixed_values or {} + fixed = fixed_values.get(excel_col, "") + if fixed: + return translate_value(excel_col, fixed) + value = row.get(excel_col, "").strip() + if value: + return translate_value(excel_col, value) + return translate_value(excel_col, defaults.get(excel_col, "")) diff --git a/log.py b/log.py new file mode 100644 index 0000000..cbbd546 --- /dev/null +++ b/log.py @@ -0,0 +1,44 @@ +"""统一日志控制 — 编译前修改 LEVEL 控制输出量。 + +等级: + 0 = 用户级:只显示处理结果和错误 + 1 = 调试级:显示所有步骤细节 + 2 = 追踪级:显示 API 请求/响应内容 +""" + +import sys +from datetime import datetime + +LEVEL = 2 # 编译时修改此值 + +_gui_callback = None # GUI 注册的回调: (msg: str) -> None + + +def set_gui_callback(cb): + """注册 GUI 日志回调,所有日志同步输出到 GUI。""" + global _gui_callback + _gui_callback = cb + + +def _fmt(msg: str) -> str: + return f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] {msg}" + + +def _emit(msg: str): + print(_fmt(msg), file=sys.stderr) + if _gui_callback: + _gui_callback(msg) + + +def user(msg: str): + _emit(msg) + + +def debug(msg: str): + if LEVEL >= 1: + _emit(msg) + + +def trace(msg: str): + if LEVEL >= 2: + _emit(f"[trace] {msg}") diff --git a/login_helper.py b/login_helper.py new file mode 100644 index 0000000..416a009 --- /dev/null +++ b/login_helper.py @@ -0,0 +1,63 @@ +"""登录助手 — 解析浏览器导出的 cookies JSON,保存为 session.json。""" + +from __future__ import annotations + +import json +import os +import sys + + +def get_session_path() -> str: + if getattr(sys, "frozen", False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(base, "session.json") + + +def parse_json_and_save(json_text: str) -> int: + """解析浏览器导出的 cookies JSON 数组,提取目标域名的 cookie 并保存。 + 返回保存的 cookie 数量。 + """ + raw = json.loads(json_text) + if not isinstance(raw, list): + raise ValueError("JSON 必须是数组格式") + + # 过滤目标域名 + target = "10.4.188.1" + cookies = [] + seen = set() + for c in raw: + domain = c.get("domain", "") + if target not in domain: + continue + name = c.get("name", "").strip() + path = c.get("path", "/") + if not name: + continue + key = (name, domain, path) + if key in seen: + continue + seen.add(key) + cookies.append({ + "name": name, + "value": c.get("value", ""), + "domain": domain, + "path": path, + }) + + data = {"cookies": cookies, "saved_from": f"https://{target}"} + session_path = get_session_path() + os.makedirs(os.path.dirname(session_path) or ".", exist_ok=True) + with open(session_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return len(cookies) + + +def load_session_info() -> dict | None: + """读取已保存的 session 信息用于 UI 展示。""" + try: + with open(get_session_path(), "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None diff --git a/main.py b/main.py new file mode 100644 index 0000000..455413a --- /dev/null +++ b/main.py @@ -0,0 +1,813 @@ +"""中国邮政揽收系统 - Excel 自动填单工具 + +主程序入口,提供 Tkinter GUI 界面。 +""" + +import asyncio +import json +import os +import sys +import threading +from datetime import datetime +from tkinter import ( + BOTH, + BOTTOM, + DISABLED, + END, + HORIZONTAL, + LEFT, + NORMAL, + RIGHT, + VERTICAL, + X, + Y, + BooleanVar, + Button, + Entry, + Frame, + Label, + LabelFrame, + Listbox, + Scrollbar, + StringVar, + Text, + Tk, + Toplevel, + filedialog, + messagebox, + ttk, +) + +from excel_reader import read_input_excel, validate_required_columns +from field_mapping import get_defaults, get_field_mapping, get_fixed_values, get_timing_config, load_config + +# ─── 全局引擎引用 ────────────────────────────────────────── + +_engine = None +_loop = None + +def _get_writable_path(name: str) -> str: + """获取可写文件路径(config.json, session.json)。 + + 开发模式:脚本所在目录。 + 打包模式:exe 所在目录(_MEIPASS 是只读的)。 + """ + if getattr(sys, 'frozen', False): + return os.path.join(os.path.dirname(sys.executable), name) + else: + return os.path.join(os.path.dirname(os.path.abspath(__file__)), name) + +def _get_config_path(): + return _get_writable_path("config.json") +def _get_session_path(): + return _get_writable_path("session.json") + +def _resource_path(relative_path): + """获取只读资源文件路径(pickup.ico 等)。 + + 开发模式:脚本所在目录。 + 打包模式:_MEIPASS(PyInstaller 解压临时目录)。 + """ + if getattr(sys, 'frozen', False): + return os.path.join(sys._MEIPASS, relative_path) + else: + return os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path) + +def _get_session_path(): + return _resource_path("session.json") + +# ─── 异步引擎后台线程 ────────────────────────────────────── + +def _run_async(coro): + """在新线程中运行异步协程。""" + global _loop + _loop = asyncio.new_event_loop() + asyncio.set_event_loop(_loop) + _loop.run_until_complete(coro) + +def _run_in_thread(coro): + """在后台线程启动异步任务。""" + t = threading.Thread(target=_run_async, args=(coro,), daemon=True) + t.start() + return t + +# ─── 主窗口 ──────────────────────────────────────────────── + +class App: + def __init__(self): + self.root = Tk() + self.root.title("自动填单工具 v2.0") + self.root.geometry("900x650") + self.root.minsize(800, 550) + if sys.platform == "win32": + self.root.iconbitmap(_resource_path("pickup.ico")) + else: + # 在 Linux 上什么都不做,或者用 .xbm 文件 + # self.root.iconbitmap(_resource_path("pickup.xbm")) + pass + + self.config = {} + self.excel_path = StringVar() + self.delay_var = StringVar(value="3") + self.status_var = StringVar(value="就绪") + self.engine_status_var = StringVar(value="未启动") + self.running = False + self.paused = False + self.results = [] + + self._build_ui() + + # 关闭窗口时清理 + self.root.protocol("WM_DELETE_WINDOW", self._on_close) + + # 将 log 模块输出重定向到 GUI 日志窗口 + from log import set_gui_callback + set_gui_callback(self._log) + + self._load_config() + + def _build_ui(self): + """构建界面。""" + # ── 配置区 ── + config_frame = LabelFrame(self.root, text="配置", padx=10, pady=5) + config_frame.pack(fill=X, padx=10, pady=5) + + # 引擎状态 + row_status = Frame(config_frame) + row_status.pack(fill=X, pady=2) + Label(row_status, text="引擎状态:", width=10, anchor="e").pack(side="left") + self.status_label = Label(row_status, textvariable=self.engine_status_var, fg="gray") + self.status_label.pack(side="left", padx=5) + + # Excel 文件 + row2 = Frame(config_frame) + row2.pack(fill=X, pady=2) + Label(row2, text="Excel文件:", width=10, anchor="e").pack(side="left") + self.file_label = Label(row2, text="未选择", fg="gray", anchor="w") + self.file_label.pack(side="left", fill=X, expand=True, padx=5) + Button(row2, text="选择文件", command=self._pick_file, width=10).pack( + side="right" + ) + + # 延时设置 + row3 = Frame(config_frame) + row3.pack(fill=X, pady=2) + Label(row3, text="间隔(秒):", width=10, anchor="e").pack(side="left") + Entry(row3, textvariable=self.delay_var, width=8).pack(side="left", padx=5) + Label(row3, text="(每单提交之间的等待时间)", fg="gray").pack(side="left") + + # 按钮 + btn_frame = Frame(config_frame) + btn_frame.pack(fill=X, pady=5) + self.login_btn = Button( + btn_frame, text="🔑 登录", command=self._do_login, width=8 + ) + self.login_btn.pack(side="left", padx=2) + self.start_btn = Button( + btn_frame, text="▶ 开始处理", command=self._start, bg="#4CAF50", + fg="white", width=12, height=1 + ) + self.start_btn.pack(side="left", padx=2) + self.pause_btn = Button( + btn_frame, text="⏸ 暂停", command=self._pause, width=10, state=DISABLED + ) + self.pause_btn.pack(side="left", padx=2) + self.stop_btn = Button( + btn_frame, text="⏹ 停止", command=self._stop, width=10, state=DISABLED + ) + self.stop_btn.pack(side="left", padx=2) + self.config_btn = Button( + btn_frame, text="编辑配置", command=self._edit_config, width=10 + ) + self.config_btn.pack(side="right", padx=2) + + # 状态 + Label( + btn_frame, textvariable=self.status_var, fg="blue" + ).pack(side="right", padx=10) + + # ── 固定值设置 ── + fixed_frame = LabelFrame(self.root, text="固定值设置(优先级:强制覆盖 > API获取值 > 固定值 > 表格 > 默认值)", padx=5, pady=5) + fixed_frame.pack(fill=X, padx=10, pady=5) + + tree_f = Frame(fixed_frame) + tree_f.pack(fill=X) + + columns = ("field", "value", "force") + self.fixed_tree = ttk.Treeview(tree_f, columns=columns, show="headings", height=4) + self.fixed_tree.heading("field", text="字段名") + self.fixed_tree.heading("value", text="固定值") + self.fixed_tree.heading("force", text="强制覆盖") + self.fixed_tree.column("field", width=100) + self.fixed_tree.column("value", width=260) + self.fixed_tree.column("force", width=70, anchor="center") + self.fixed_tree.pack(side="left", fill=X, expand=True) + + btn_f = Frame(fixed_frame) + btn_f.pack(fill=X, pady=2) + Button(btn_f, text="添加", command=self._add_fixed_value, width=6).pack(side="left", padx=2) + Button(btn_f, text="删除", command=lambda: [self.fixed_tree.delete(i) for i in self.fixed_tree.selection()], width=6).pack(side="left", padx=2) + Button(btn_f, text="保存", command=self._save_fixed_values, width=6, bg="#4CAF50", fg="white").pack(side="left", padx=2) + Label(btn_f, text="双击编辑值 | 单击'强制覆盖'列切换 | 留空则不生效", fg="gray").pack(side="left", padx=5) + + self.fixed_tree.bind("", self._edit_fixed_cell) + self.fixed_tree.bind("", self._toggle_force) + + # ── 进度条 ── + progress_frame = Frame(self.root) + progress_frame.pack(fill=X, padx=10, pady=2) + self.progress = ttk.Progressbar( + progress_frame, mode="determinate", length=100 + ) + self.progress.pack(fill=X) + self.progress_label = Label(progress_frame, text="", fg="gray") + self.progress_label.pack() + + # ── 日志区 ── + log_frame = LabelFrame(self.root, text="运行日志", padx=10, pady=5) + log_frame.pack(fill="both", expand=True, padx=10, pady=5) + + self.log_text = Text(log_frame, wrap="word", state=DISABLED, font=("Consolas", 9)) + self.log_text.pack(side="left", fill="both", expand=True) + + scroll_y = Scrollbar(log_frame, orient=VERTICAL, command=self.log_text.yview) + scroll_y.pack(side=RIGHT, fill=Y) + self.log_text.configure(yscrollcommand=scroll_y.set) + + # ─── 配置管理 ─────────────────────────────────────────── + + def _load_config(self): + """加载配置文件。首次运行时从 _MEIPASS 复制到 exe 目录。""" + try: + config_path = _get_config_path() + # 打包模式首次运行:从 _MEIPASS 复制 config.json 到可写目录 + if getattr(sys, 'frozen', False) and not os.path.exists(config_path): + bundled = os.path.join(sys._MEIPASS, "config.json") + if os.path.exists(bundled): + import shutil + shutil.copy2(bundled, config_path) + self.config = load_config(config_path) + timing = get_timing_config(self.config) + delay_sec = timing.get("delay_between_rows_ms", 3000) // 1000 + self.delay_var.set(str(delay_sec)) + + # 加载固定值到表格 + for item in self.fixed_tree.get_children(): + self.fixed_tree.delete(item) + for field, cfg in self.config.get("fixed_values", {}).items(): + val = cfg.get("value", "") + force = "✓" if cfg.get("force") else "" + self.fixed_tree.insert("", "end", values=(field, val, force)) + + self._log("配置加载成功") + + # 检查已保存的登录会话 + self._check_session_status() + except Exception as e: + self._log(f"[error] 配置加载失败: {e}") + messagebox.showwarning("配置错误", f"配置文件加载失败:\n{e}") + + def _check_session_status(self): + """检查已保存的登录会话状态并更新 UI。""" + try: + from login_helper import load_session_info + info = load_session_info() + if info and len(info.get("cookies", [])) > 0: + self.engine_status_var.set(f"已登录 ({len(info['cookies'])} cookies)") + self.status_label.configure(fg="green") + else: + self.engine_status_var.set("未登录") + self.status_label.configure(fg="orange") + except Exception: + self.engine_status_var.set("未登录") + self.status_label.configure(fg="orange") + + def _edit_config(self): + """打开配置编辑窗口。""" + config_path = _get_config_path() + # os.startfile(config_path) if sys.platform == "win32" else None + + win = Toplevel(self.root) + win.title("编辑配置") + win.geometry("700x500") + + text = Text(win, wrap="none", font=("Consolas", 10)) + text.pack(fill="both", expand=True, padx=5, pady=5) + + with open(config_path, "r", encoding="utf-8") as f: + text.insert("1.0", f.read()) + + def save(): + new_config = text.get("1.0", "end-1c") + try: + json.loads(new_config) # 验证 JSON + with open(config_path, "w", encoding="utf-8") as f: + f.write(new_config) + self._load_config() + win.destroy() + self._log("配置已保存并重新加载") + except json.JSONDecodeError as e: + messagebox.showerror("JSON 错误", f"配置格式错误:\n{e}") + + Button(win, text="保存", command=save, bg="#4CAF50", fg="white").pack( + side="bottom", pady=5 + ) + + # ─── 固定值设置 ──────────────────────────────────────── + + def _edit_fixed_cell(self, event): + """双击编辑固定值单元格(跳过强制覆盖列)。""" + region = self.fixed_tree.identify_region(event.x, event.y) + if region != "cell": + return + column = self.fixed_tree.identify_column(event.x) + if column == "#3": # 强制覆盖列不双击编辑,单击切换 + return + item = self.fixed_tree.identify_row(event.y) + if not item: + return + col_idx = int(column.replace("#", "")) - 1 + old_val = self.fixed_tree.item(item, "values")[col_idx] + + edit = Entry(self.root) + edit.insert(0, old_val) + edit.select_range(0, "end") + bbox = self.fixed_tree.bbox(item, column) + if bbox: + edit.place(x=bbox[0] + 185, y=bbox[1] + 310, width=bbox[2], height=bbox[3]) + + def save_edit(e=None): + vals = list(self.fixed_tree.item(item, "values")) + vals[col_idx] = edit.get() + self.fixed_tree.item(item, values=vals) + edit.destroy() + edit.bind("", save_edit) + edit.bind("", lambda e: edit.destroy()) + edit.focus_set() + + def _add_fixed_value(self): + """添加新的固定值行(默认不强制覆盖)。""" + self.fixed_tree.insert("", "end", values=("新固定值", "", "")) + + def _toggle_force(self, event): + """单击'强制覆盖'列切换开关状态。""" + region = self.fixed_tree.identify_region(event.x, event.y) + if region != "cell": + return + column = self.fixed_tree.identify_column(event.x) + if column != "#3": # force 是第3列 + return + item = self.fixed_tree.identify_row(event.y) + if not item: + return + vals = list(self.fixed_tree.item(item, "values")) + vals[2] = "" if vals[2] == "✓" else "✓" + self.fixed_tree.item(item, values=vals) + + def _save_fixed_values(self): + """保存固定值到 config.json(新格式:{value, force})。""" + new_fixed = {} + for item in self.fixed_tree.get_children(): + vals = self.fixed_tree.item(item, "values") + field_name = vals[0].strip() + if field_name: + new_fixed[field_name] = { + "value": vals[1].strip() if len(vals) > 1 else "", + "force": vals[2] == "✓" if len(vals) > 2 else False, + } + self.config["fixed_values"] = new_fixed + + config_path = _get_config_path() + with open(config_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + cfg["fixed_values"] = new_fixed + with open(config_path, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=4) + + self._log(f"固定值已保存: {len(new_fixed)} 项") + + # ─── 文件选择 ─────────────────────────────────────────── + + def _pick_file(self): + path = filedialog.askopenfilename( + title="选择 Excel 文件", + filetypes=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")], + ) + if path: + self.excel_path.set(path) + self.file_label.configure(text=path, fg="black") + + def _on_close(self): + """关闭窗口;atexit 会强制清理 Node.js 子进程。""" + global _engine + if _engine: + _engine.request_stop() + self.root.destroy() + + # ─── 控制按钮 ─────────────────────────────────────────── + + def _do_login(self): + """弹窗让用户粘贴一键导出的 cookies JSON。""" + from login_helper import parse_json_and_save, load_session_info + + win = Toplevel(self.root) + win.title("登录揽收系统") + win.geometry("620x360") + win.transient(self.root) + win.grab_set() + + Label(win, text="登录揽收系统", font=("", 12, "bold"), pady=8).pack() + + Label(win, text="使用 Cookies 导出工具,将 10.4.188.1 的所有 cookies 导出为 JSON,粘贴到下方:").pack(anchor="w", padx=10) + + json_text = Text(win, height=12, wrap="word", font=("Consolas", 9)) + json_text.pack(fill=BOTH, expand=True, padx=10, pady=5) + + def do_save(): + try: + n = parse_json_and_save(json_text.get("1.0", "end-1c")) + self._log(f"已保存 {n} 个 cookies 到 session.json") + self.engine_status_var.set("已登录") + self.status_label.configure(fg="green") + self._check_session_status() + win.destroy() + except Exception as e: + messagebox.showerror("解析失败", f"JSON 解析失败:\n{e}", parent=win) + + btn_f = Frame(win) + btn_f.pack(pady=10) + Button(btn_f, text="保存并关闭", command=do_save, + bg="#4CAF50", fg="white", width=15, height=1).pack() + + info = load_session_info() + if info: + Label(win, text=f"当前已保存: {len(info.get('cookies', []))} 个 cookies", + fg="gray").pack() + + def _start(self): + if self.running: + return + + if not self.excel_path.get(): + messagebox.showwarning("提示", "请先选择 Excel 文件") + return + + try: + rows, headers = read_input_excel(self.excel_path.get()) + except Exception as e: + messagebox.showerror("读取错误", f"读取 Excel 失败:\n{e}") + return + + if not rows: + messagebox.showwarning("提示", "Excel 中没有数据") + return + + # 验证必要字段是否有数据来源(固定值 > Excel > 默认值) + fixed_values = get_fixed_values(self.config) + defaults = get_defaults(self.config) + missing = validate_required_columns(headers, fixed_values, defaults) + if missing: + msg = ( + f"以下字段在所有来源中均未找到值:\n" + + "\n".join(f" - {c}(请填入Excel / 固定值 / 默认值任一)" for c in missing) + ) + messagebox.showwarning("缺少必要字段", msg) + return + + self.running = True + self.paused = False + self._set_controls_state("running") + + # 创建结果工作簿 + self.results = [] + + # 更新配置中的延时 + try: + delay_ms = int(self.delay_var.get()) * 1000 + self.config.setdefault("timing", {})["delay_between_rows_ms"] = delay_ms + except ValueError: + pass + + self._log(f"开始处理 {len(rows)} 条记录...") + self.progress["maximum"] = len(rows) + self.progress["value"] = 0 + self.progress_label.configure(text=f"0 / {len(rows)}") + + # 在后台线程启动异步引擎 + _run_in_thread(self._run_engine(rows, headers)) + + async def _run_engine(self, rows, headers): + """后台运行引擎(在 async 线程中执行)。""" + global _engine + + try: + from engine import PickupEngine + + field_mapping = get_field_mapping(self.config) + defaults = get_defaults(self.config) + timing = get_timing_config(self.config) + fixed_values = get_fixed_values(self.config) + + _engine = PickupEngine( + url="https://10.4.188.1/pickup-web/a/postwaybill/main", + field_mapping=field_mapping, + defaults=defaults, + timing=timing, + log_callback=self._log_async, + fixed_values=fixed_values, + choose_callback=self._choose_callback, + confirm_callback=self._confirm_callback, + ) + + self._update_status("启动中...") + try: + await _engine.start() + except Exception as e: + self._log_async(f"[error] 启动失败: {e}") + self._update_status("启动失败") + await _engine.stop() + return + + # 验证 engine.start() 中已加载了 session,且包含关键 cookie + if not _engine._sess or not _engine._sess._http.cookies: + self._log_async("[提示] 未找到登录会话,请点击'登录'按钮完成登录后再开始处理") + self._update_status("未登录") + await _engine.stop() + return + cookie_names = {c.name for c in _engine._sess._http.cookies} + missing = [n for n in ("JSESSIONID", "jdpt.session.id") if n not in cookie_names] + if missing: + self._log_async(f"[提示] 缺少关键 cookie: {', '.join(missing)},请重新登录并手动填写") + self._update_status("未登录") + await _engine.stop() + return + + # 初始化页面资源和 token bridge + try: + await _engine.begin_processing() + self._update_status("后台运行中") + self._log_async("HTTP API 引擎已就绪") + except Exception as e: + self._log_async(f"[error] 初始化失败: {e}") + self._update_status("启动失败") + await _engine.stop() + return + + for i, row in enumerate(rows): + if _engine._stop_requested: + break + + result = await _engine.process_single_row(row) + self.results.append(result) + + # 更新进度 + self.root.after(0, lambda cur=i+1, total=len(rows): self._update_progress(cur, total)) + + # 行间延时 + delay = timing.get("delay_between_rows_ms", 3000) / 1000 + if i < len(rows) - 1: + await asyncio.sleep(delay) + + self._log_async(f"\n处理完成!共处理 {len(self.results)} 条记录") + + except Exception as e: + self._log_async(f"[error] 引擎异常: {e}") + import traceback + traceback.print_exc() + finally: + if _engine: + await _engine.stop() + self.root.after(0, self._on_finished) + + def _update_progress(self, current, total): + self.progress["value"] = current + self.progress_label.configure(text=f"{current} / {total}") + self.status_var.set(f"处理中: {current}/{total}") + + def _on_finished(self): + global _engine + self.running = False + self.paused = False + _engine = None + self._set_controls_state("stopped") + self.status_var.set("就绪") + self._log("处理结束") + + def _pause(self): + global _engine + if not self.running: + return + if self.paused: + self.paused = False + if _engine: + _engine.resume() + self.pause_btn.configure(text="⏸ 暂停") + self.status_var.set("处理中...") + self._log("已恢复") + else: + self.paused = True + if _engine: + _engine.request_pause() + self.pause_btn.configure(text="▶ 继续") + self.status_var.set("已暂停") + self._log("已暂停") + + def _stop(self): + global _engine + self.running = False + if _engine: + _engine.request_stop() + self._set_controls_state("stopped") + self.status_var.set("正在停止...") + self._log("用户停止") + + def _set_controls_state(self, state): + if state == "running": + self.start_btn.configure(state=DISABLED) + self.pause_btn.configure(state=NORMAL) + self.stop_btn.configure(state=NORMAL) + else: + self.start_btn.configure(state=NORMAL) + self.pause_btn.configure(state=DISABLED, text="⏸ 暂停") + self.stop_btn.configure(state=DISABLED) + + # ─── 日志 ─────────────────────────────────────────────── + + def _log(self, msg): + """线程安全的日志输出。""" + self.root.after(0, self._log_sync, msg) + + def _log_async(self, msg): + """从异步线程调用(直接入队到 GUI 线程)。""" + self.root.after(0, self._log_sync, msg) + + def _update_status(self, status: str): + """更新引擎状态显示。""" + color_map = {"后台运行中": "green", "启动失败": "red", "未登录": "orange"} + self.root.after(0, lambda: self.engine_status_var.set(status)) + self.root.after(0, lambda s=status: self.status_label.configure( + fg=color_map.get(s, "gray") + )) + + + async def _choose_callback(self, title: str, options: list[str]) -> int | None: + """弹出选择框让用户从 autocomplete 结果中选择。返回索引或 None。""" + loop = asyncio.get_event_loop() + future = loop.create_future() + result = {"index": None} + + def show(): + win = Toplevel(self.root) + win.title(title) + win.geometry("400x300") + win.transient(self.root) + win.grab_set() + + Label(win, text=f"请选择{title},选择后批量复用:", pady=10).pack() + + frame = Frame(win) + frame.pack(fill="both", expand=True, padx=10) + listbox = Listbox(frame) + scroll = Scrollbar(frame, orient=VERTICAL, command=listbox.yview) + listbox.configure(yscrollcommand=scroll.set) + scroll.pack(side=RIGHT, fill=Y) + listbox.pack(side=LEFT, fill=BOTH, expand=True) + + for i, opt in enumerate(options): + listbox.insert(END, f"[{i+1}] {opt}") + + def on_select(): + sel = listbox.curselection() + if sel: + result["index"] = sel[0] + win.destroy() + + listbox.bind("", lambda e: on_select()) + + # 关闭窗口 → result["index"] 为 None → engine 停止 batch + def on_cancel(): + result["index"] = None + win.destroy() + + btn_frame = Frame(win) + btn_frame.pack(pady=10) + Button(btn_frame, text="确认选择", command=on_select, + bg="#4CAF50", fg="white", width=12).pack(side=LEFT, padx=5) + Button(btn_frame, text="取消", command=on_cancel, + bg="red", fg="white", width=8).pack(side=LEFT, padx=5) + + win.protocol("WM_DELETE_WINDOW", on_cancel) + listbox.focus_set() + + # 等窗口关闭后通知 async 线程 + def wait_then_resolve(): + win.wait_window() + loop.call_soon_threadsafe(lambda: future.set_result(result["index"])) + threading.Thread(target=wait_then_resolve, daemon=True).start() + + self.root.after(0, show) + return await future + + async def _confirm_callback(self, fields: dict[str, str]) -> bool: + """弹出确认框显示表单数据,用户确认后返回 True。""" + loop = asyncio.get_event_loop() + future = loop.create_future() + result = {"confirmed": False} + + def show(): + win = Toplevel(self.root) + win.title("确认提交信息 - 确认后自动批量处理") + win.geometry("450x400") + win.transient(self.root) + win.grab_set() + + Label(win, text="请确认以下信息无误后点击确认:", pady=10, + font=("", 10, "bold")).pack() + + frame = Frame(win) + frame.pack(fill=BOTH, expand=True, padx=20, pady=5) + + text = Text(frame, wrap="word", font=("Consolas", 10), height=16) + text.pack(side=LEFT, fill=BOTH, expand=True) + scroll = Scrollbar(frame, command=text.yview) + scroll.pack(side=RIGHT, fill=Y) + text.configure(yscrollcommand=scroll.set) + + # 系统标记(不展示) + from engine import _SYSTEM_FLAGS + sys_keys = set(_SYSTEM_FLAGS.keys()) + + # 关键字段:始终展示,值也翻译为中文 + from field_mapping import reverse_translate + key_fields = [ + ("产品名称", "bizProductName", ""), + ("客户名称", "senders", ""), + ("客户代码", "senderNo", ""), + ("邮件号", "waybillNo", ""), + ("重量(g)", "realWeight", ""), + ("寄达地", "receiverArriveOrgName", ""), + ("收件人", "receiverLinker", ""), + ("收件人手机", "receiverMobile", ""), + ("收件人地址", "receiverAddr", ""), + ("寄件人", "senderLinker", ""), + ("寄件人手机", "senderMobile", ""), + ("寄件人地址", "senderAddr", ""), + ("运输类型", "transferType", "运输类型"), + ("内件属性", "contentsAttribute", "内件属性"), + ("电商平台", "ecommerceNo", "电商平台"), + ("付款方式", "paymentMode", "付款方式"), + ("内件品名", "reserved5", ""), + ("保价金额", "insuranceAmount", ""), + ("代收货款", "codAmount", ""), + ("备注", "senderNotes", ""), + ] + if not isinstance(fields, dict): + text.insert(END, f"错误: 表单数据异常 ({type(fields).__name__})") + text.configure(state=DISABLED) + return + shown = set() + for label, key, col_name in key_fields: + val = fields.get(key, "") or "空" + if col_name and val != "空": + val = reverse_translate(col_name, val) + text.insert(END, f"{label}: {val}\n") + shown.add(key) + # 展示其余非系统标记的有值字段 + for k, v in fields.items(): + if v and k not in shown and k not in sys_keys: + text.insert(END, f"{k}: {v}\n") + text.configure(state=DISABLED) + + btn_frame = Frame(win) + btn_frame.pack(pady=10) + Button(btn_frame, text="确认提交", + command=lambda: (result.__setitem__("confirmed", True), win.destroy()), + bg="#4CAF50", fg="white", width=12, height=1).pack(side=LEFT, padx=10) + Button(btn_frame, text="取消", + command=win.destroy, + bg="red", fg="white", width=10).pack(side=LEFT, padx=10) + + def wait_then_resolve(): + win.wait_window() + loop.call_soon_threadsafe(lambda: future.set_result(result["confirmed"])) + threading.Thread(target=wait_then_resolve, daemon=True).start() + + self.root.after(0, show) + return await future + + def _log_sync(self, msg): + timestamp = datetime.now().strftime("%H:%M:%S") + line = f"[{timestamp}] {msg}\n" + self.log_text.configure(state=NORMAL) + self.log_text.insert(END, line) + self.log_text.see(END) + self.log_text.configure(state=DISABLED) + +# ─── 入口 ────────────────────────────────────────────────── + +def main(): + app = App() + app.root.mainloop() + +if __name__ == "__main__": + main() diff --git a/pickup.ico b/pickup.ico new file mode 100644 index 0000000..e818e4b Binary files /dev/null and b/pickup.ico differ diff --git a/pickup_api.py b/pickup_api.py new file mode 100644 index 0000000..33d7a30 --- /dev/null +++ b/pickup_api.py @@ -0,0 +1,604 @@ +"""揽收系统 HTTP API 封装 — 直接调用后端接口,不依赖浏览器。 + +依赖 Node.js token bridge 计算 $_ts 安全框架的 c93l5Fya 动态 token。 +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from typing import Any +from urllib.parse import urlparse, urljoin + +from log import debug + + +# ─── Token Bridge 管理 ──────────────────────────────────────────────── + +def _get_bridge_dir() -> str: + """获取 token_bridge 目录路径(支持 PyInstaller 打包)。""" + if getattr(sys, "frozen", False): + base = sys._MEIPASS + else: + base = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(base, "token_bridge") + + +class TokenBridge: + """管理 Node.js token 计算子进程(HTTP 通信)。""" + + def __init__(self): + self._proc: subprocess.Popen | None = None + self._port: int = 0 + self._base_url: str = "" + + def start(self): + bridge_dir = _get_bridge_dir() + kwargs = dict( + cwd=bridge_dir, + stderr=subprocess.PIPE, + text=True, encoding="utf-8", errors="replace", + ) + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + self._proc = subprocess.Popen(["node", "server.js", "0"], **kwargs) + + # 进程退出时强制杀子进程,防止 _MEIPASS 目录无法删除 + import atexit as _atexit + _atexit.register(self.stop) + # 读取就绪信号(包含端口号) + line = self._proc.stderr.readline() + try: + msg = json.loads(line.strip()) + if not msg.get("ready"): + raise RuntimeError(f"Bridge startup failed: {line}") + self._port = msg["port"] + self._base_url = f"http://127.0.0.1:{self._port}" + except json.JSONDecodeError: + raise RuntimeError(f"Bridge unexpected stderr: {line}") + + # 后台线程持续读取 stderr 并输出到 log + import threading as _threading + def _read_stderr(): + from log import trace + for line in self._proc.stderr: + trace(f"bridge stderr: {line.strip()}") + _threading.Thread(target=_read_stderr, daemon=True).start() + + def stop(self): + if self._proc: + try: + self._proc.kill() + self._proc.wait(timeout=3) + except Exception: + pass + self._proc = None + self._port = 0 + + def _request(self, path: str, body: dict = None) -> dict: + """向 bridge HTTP 服务器发送请求。""" + from log import debug, trace + import requests as _requests + url = f"{self._base_url}{path}" + trace(f"bridge -> {path} {str(body)[:100] if body else ''}") + try: + if body: + resp = _requests.post(url, json=body, timeout=30) + else: + resp = _requests.get(url, timeout=30) + resp.raise_for_status() + result = resp.json() + trace(f"bridge <- {str(result)[:100]}") + if "error" in result: + raise RuntimeError(f"Bridge error: {result['error']}") + return result + except _requests.RequestException as e: + # 读取 stderr + err = "" + try: + while True: + chunk = self._proc.stderr.readline() + if not chunk: + break + err += chunk + except Exception: + pass + if err: + raise RuntimeError(f"Bridge request failed: {e}, stderr={err[:300]}") + raise + + def initialize(self, html: str, scripts: list[dict], base_url: str): + return self._request("/init", { + "html": html, + "scripts": scripts, + "baseUrl": base_url, + }) + + def compute_token(self, method: str, path: str) -> str: + result = self._request("/token", { + "method": method, + "path": path, + }) + return result["token"] + + def ping(self) -> bool: + try: + self._request("/ping") + return True + except Exception: + return False + + +# ─── HTTP 会话 & 页面资源管理 ───────────────────────────────────────── + +class PickupSession: + """管理 HTTP 会话(cookies)和页面资源加载。""" + + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + parsed = urlparse(self.base_url) + self._origin = f"{parsed.scheme}://{parsed.netloc}" + # 使用 requests 保持 cookie 会话(同步,足够简单可靠) + import requests as _requests + self._http: _requests.Session = _requests.Session() + self._http.verify = False # 内部自签证书 + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + self._http.headers.update({ + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/109.0.0.0 Safari/537.36" + ), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + }) + self._scripts: list[dict] = [] + self._html: str = "" + self._pickup_path: str = "" + self._ts_cd: str = "" + self._ts_nsd: int = 0 + + @property + def cookies_str(self) -> str: + """将 requests cookies 转为 Cookie 请求头字符串。""" + pairs = [] + for cookie in self._http.cookies: + pairs.append(f"{cookie.name}={cookie.value}") + return "; ".join(pairs) + + def load_session(self, session_path: str) -> bool: + """从文件加载之前保存的 cookies。返回是否加载成功。""" + import requests as _requests + try: + with open(session_path, "r") as f: + data = json.load(f) + seen = set() + for cookie_data in data.get("cookies", []): + key = (cookie_data["name"], cookie_data.get("domain", ""), cookie_data.get("path", "/")) + if key in seen: + continue + seen.add(key) + self._http.cookies.set( + cookie_data["name"], + cookie_data["value"], + domain=cookie_data.get("domain", ""), + path=cookie_data.get("path", "/"), + ) + return len(seen) > 0 + except (FileNotFoundError, json.JSONDecodeError, KeyError): + return False + + def save_session(self, session_path: str): + """保存 cookies 到文件。""" + data = { + "cookies": [ + {"name": c.name, "value": c.value, "domain": c.domain, "path": c.path} + for c in self._http.cookies + ] + } + with open(session_path, "w") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def _abs_url(self, path_or_url: str) -> str: + if path_or_url.startswith("http"): + return path_or_url + return urljoin(self._origin, path_or_url) + + def fetch_page_resources(self) -> dict: + """加载 pickup 页面及所需 JS 文件。返回 {html, scripts, base_url, nsd}。""" + import requests as _requests + + pickup_path = urlparse(self.base_url).path + self._pickup_path = pickup_path + + # 加载 pickup 页面(需要 Referer 满足安全框架要求) + portal_url = f"{self._origin}/portal/a" + debug(f"fetch_page: loading {self.base_url}, cookies={list(self._http.cookies.keys())}") + resp = self._http.get( + self.base_url, + headers={ + "Referer": portal_url, + "Sec-Fetch-Dest": "iframe", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "same-origin", + "Upgrade-Insecure-Requests": "1", + }, + timeout=30, + ) + debug(f"fetch_page: status={resp.status_code}") + if resp.status_code != 200: + debug(f"fetch_page: resp_headers={dict(resp.headers)}") + debug(f"fetch_page: resp_body={resp.text[:500]}") + resp.raise_for_status() + self._html = resp.text + + # 从页面 HTML 中提取 $_ts 安全参数 + self._ts_cd = "" + self._ts_nsd = 0 + m = re.search(r'\$_ts\.cd="([^"]*)"', self._html) + if m: + self._ts_cd = m.group(1) + m = re.search(r'\$_ts\.nsd=(\d+)', self._html) + if m: + self._ts_nsd = int(m.group(1)) + if not self._ts_cd or not self._ts_nsd: + raise RuntimeError("Failed to extract $_ts.cd/nsd from page") + + # 2. 提取外部 JS 引用,只保留 security JS(cfd114a.js) + # 页面业务脚本(jQuery、main.js 等)会导致 jsdom 卡死,不需要 + script_urls = re.findall( + r']+src="([^"]+)"[^>]*>', self._html, re.IGNORECASE + ) + self._scripts = [] + for url in script_urls: + if "cfd114a.js" not in url: + continue + abs_url = self._abs_url(url) + try: + script_resp = self._http.get(abs_url, timeout=15) + if script_resp.status_code == 200: + self._scripts.append({"url": url, "content": script_resp.text}) + debug(f"fetch_page: loaded security JS {url}") + except _requests.RequestException: + debug(f"Failed to fetch script: {abs_url}") + + if not self._scripts: + raise RuntimeError("Security JS (cfd114a.js) not found on page") + + return { + "html": self._html, + "scripts": self._scripts, + "base_url": self.base_url, + "ts_cd": self._ts_cd, + "ts_nsd": self._ts_nsd, + } + + def request(self, method: str, path: str, token: str, + data: dict[str, str] = None) -> dict[str, Any]: + """向 pickup-web 后端发送 API 请求。 + + Args: + method: HTTP 方法 (GET/POST) + path: API 路径 (如 /pickup-web/a/common/customerautocomplete) + token: c93l5Fya 安全 token + data: POST 表单数据 + + Returns: + JSON 响应(如果是 JSON),否则返回 {"_text": response_text} + """ + full_url = self._abs_url(path) + if "?" in full_url: + full_url += f"&c93l5Fya={token}" + else: + full_url += f"?c93l5Fya={token}" + + headers = { + "X-Requested-With": "XMLHttpRequest", + "Accept": "application/json, text/javascript, */*; q=0.01", + "Referer": self.base_url, + } + if data: + headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8" + + debug(f"API {method} {path}") + resp = self._http.request( + method, full_url, + headers=headers, + data=data or None, + timeout=30, + ) + debug(f"API {method} {path} → {resp.status_code}") + if resp.status_code >= 400: + debug(f"API error body: {resp.text[:500]}") + resp.raise_for_status() + + text = resp.text + if text and (" str: + return self._bridge.compute_token(method, path) + + def _post(self, path: str, data: dict[str, str] = None) -> dict: + token = self._token("POST", path) + return self._sess.request("POST", path, token, data) + + def _get(self, path: str) -> dict: + token = self._token("GET", path) + return self._sess.request("GET", path, token) + + # ── 产品 ────────────────────────────────────────────────── + + def query_product(self, keyword: str) -> list[dict]: + """搜索产品名称,返回候选列表(data=id|bizProductNo, value=显示名)。""" + result = self._post( + "/pickup-web/a/postwaybilledit/queryproductmanager", + data={"no": "on", "query": keyword}, + ) + from log import trace + trace(f"query_product raw: {str(result)[:300]}") + suggestions = result.get("suggestions", []) if isinstance(result, dict) else [] + # 解析 data 字段: "productId|bizProductNo" + parsed = [] + for s in suggestions: + data_parts = s.get("data", "").split("|") + parsed.append({ + "productId": data_parts[0] if len(data_parts) > 0 else "", + "bizProductNo": data_parts[1] if len(data_parts) > 1 else "", + "productName": s.get("value", ""), + }) + return parsed + + def select_product(self, biz_product_id: str, biz_product_no: str) -> dict: + """选择产品后获取产品详情(模板、材料等)。""" + product_info = {"bizProductId": biz_product_id, "bizProductNo": biz_product_no} + + # 获取模板(返回数组,取第一个) + template = self._post( + "/pickup-web/a/common/templatecodemodel", + data={"bizProductNo": biz_product_no}, + ) + tmpl = template[0] if isinstance(template, list) and template else template + if isinstance(tmpl, dict): + product_info.update(tmpl) + + # 查询产品详情 + detail = self._post( + "/pickup-web/a/common/queryproductbymanager", + data={ + "bizProductId": biz_product_id, + "bizProductNo": biz_product_no, + }, + ) + if isinstance(detail, dict): + product_info.update(detail) + elif isinstance(detail, list) and detail: + product_info.update(detail[0]) + + # 查询物料信息(返回数组或 dict) + material = self._post( + "/pickup-web/a/common/findppoductmaterialid", + data={"productCode": biz_product_no}, + ) + mat = material[0] if isinstance(material, list) and material else material + if isinstance(mat, dict): + product_info["material"] = mat + + return product_info + + # ── 客户 ────────────────────────────────────────────────── + + def query_customer(self, keyword: str) -> list[dict]: + """搜索客户名称,返回候选列表(data=customerId, valueName=短名, value=全名)。""" + result = self._post( + "/pickup-web/a/common/customerautocomplete", + data={"query": keyword}, + ) + from log import trace + trace(f"query_customer raw: {str(result)[:300]}") + suggestions = result.get("suggestions", []) if isinstance(result, dict) else [] + parsed = [] + for s in suggestions: + parsed.append({ + "customerId": s.get("data", ""), + "customerName": s.get("valueName", s.get("value", "")), + "customerFullName": s.get("value", ""), + }) + return parsed + + def select_customer(self, customer_id: str) -> dict: + """选择客户后获取客户详情。""" + result = self._post( + "/pickup-web/a/common/customerins", + data={"customerId": customer_id}, + ) + if isinstance(result, dict): + return result + return {} + + def find_sender_history(self, sender_no: str) -> dict: + """根据寄件人编号查询实名历史。""" + result = self._post( + "/pickup-web/a/postwaybillargs/findrealnamehistorybysender", + data={"senderNo": sender_no}, + ) + if isinstance(result, dict): + return result + return {} + + # ── 运单 ────────────────────────────────────────────────── + + def query_waybill(self, waybill_no: str, ecommerce_no: str, + biz_product_no: str, payment_mode: str, + biz_product_id: str = "", + post_blend: str = "0", + lock_customer: str = "0") -> dict: + """查询运单信息。""" + return self._post( + "/pickup-web/a/postwaybill/querypostwaybillmanager", + data={ + "waybillNo": waybill_no, + "ecommerceNo": ecommerce_no, + "bizProductNo": biz_product_no, + "postBlend": post_blend, + "lockCustomer": lock_customer, + "paymentMode": payment_mode, + "bizProductId": biz_product_id, + }, + ) + + # ── 地址 ────────────────────────────────────────────────── + + def address_match_raw(self, receiver_addr: str) -> dict: + """根据地址文本查询区划代码(addressMatch API)。""" + return self._post( + "/pickup-web/a/postwaybill/addressMatch", + data={"receiverAddr": receiver_addr}, + ) + + def query_district(self, dist_code: str) -> dict: + """根据区划代码查询地址基本信息。""" + return self._post( + "/pickup-web/a/postwaybilledit/addressbasicno", + data={"dto": dist_code}, + ) + + def match_address(self, receiver_arrive_org_no: str, + base_product_no: str, + waybill_no: str, + receiver_addr: str, + sender_no: str = "", + transfer_type: str = "3", + contents_attribute: str = "3", + single_seal_flag: str = "0", + inter_flag: str = "0", + reserved29: str = "0", + safety_type: str = "0", + io_type: str = "10") -> dict: + """匹配收件地址,获取寄达地网格等信息。""" + return self._post( + "/pickup-web/a/common/findGridPrammer", + data={ + "receiverArriveOrgNo": receiver_arrive_org_no, + "baseProductNo": base_product_no, + "transferType": transfer_type, + "contentsAttribute": contents_attribute, + "singleSealFlag": single_seal_flag, + "interFlag": inter_flag, + "reserved29": reserved29, + "waybillNo": waybill_no, + "receiverAddr": receiver_addr, + "senderNo": sender_no, + "safetyType": safety_type, + "ioType": io_type, + }, + ) + + # ── 校验 ────────────────────────────────────────────────── + + def check_product_material(self, cargo_names: str, waybill_no: str, + product_code: str, dist_code: str) -> dict: + """校验产品物料。""" + return self._post( + "/pickup-web/a/common/checkppoductmaterialidnew", + data={ + "cargoNames": cargo_names, + "cargoOptions": "", + "waybillNo": waybill_no, + "productCode": product_code, + "distCode": dist_code, + }, + ) + + def check_reach_area(self, product_code: str, dist_code: str, + cust_code: str, weight: str, waybill_no: str) -> dict: + """校验可达区域。""" + return self._post( + "/pickup-web/a/common/reachareaverification", + data={ + "productCode": product_code, + "distCode": dist_code, + "custCode": cust_code, + "weight": weight, + "waybillNo": waybill_no, + }, + ) + + def check_important_address(self, waybill_no: str, addr: str) -> dict: + """检查重要地址。""" + return self._post( + "/pickup-web/a/postwaybill/importantaddress", + data={"waybillNo": waybill_no, "queryAddr": addr}, + ) + + def check_sensitive_customers(self, mobile_phones: str, waybill_no: str) -> dict: + """检查敏感客户。""" + return self._post( + "/pickup-web/a/postwaybillargs/findsensitivecustomers", + data={"mobilePhone": mobile_phones, "waybillNo": waybill_no}, + ) + + # ── 杂项查询 ────────────────────────────────────────────── + + def query_ecommerce(self, query: str = "B") -> list: + """查询电商平台列表。""" + return self._post( + "/pickup-web/a/postwaybill/queryecommerce", + data={"query": query}, + ) + + def query_package_sort(self) -> list: + """查询包装分类。""" + return self._post("/pickup-web/a/common/querypackagesort") + + def query_deliver_date(self) -> dict: + """查询可投递日期。""" + return self._post("/pickup-web/a/postwaybillargs/deliverdate") + + def query_reason_temp_storage(self) -> dict: + """查询暂存原因列表。""" + return self._post("/pickup-web/a/postwaybill/queryreasonfortemporarystorage") + + def check_customer_warning(self) -> dict: + """检查客户预警。""" + return self._post("/pickup-web/a/common/customerwarning") + + def check_sheet_warning(self) -> dict: + """检查面单预警。""" + return self._post("/pickup-web/a/postwaybill/sheetWarning") + + # ── 提交 ────────────────────────────────────────────────── + + def submit(self, form_data: dict[str, str]) -> dict: + """提交揽收表单。返回 {"resCode": "", "resMess": "收寄成功", ...}。""" + return self._post( + "/pickup-web/a/postwaybill/isnotetosave", + data=form_data, + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b7daebd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "pickup-auto" +version = "2.0.0" +description = "Add your description here" +requires-python = ">=3.10" +dependencies = [ + "openpyxl>=3.1.5", + "requests>=2.31.0", + "pyinstaller>=6.20.0", +] diff --git a/sample_input.xlsx b/sample_input.xlsx new file mode 100644 index 0000000..137888b Binary files /dev/null and b/sample_input.xlsx differ diff --git a/token_bridge/package-lock.json b/token_bridge/package-lock.json new file mode 100644 index 0000000..e5914b4 --- /dev/null +++ b/token_bridge/package-lock.json @@ -0,0 +1,783 @@ +{ + "name": "pickup-token-bridge", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pickup-token-bridge", + "version": "1.0.0", + "dependencies": { + "jsdom": "^24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + } + } +} diff --git a/token_bridge/package.json b/token_bridge/package.json new file mode 100644 index 0000000..a44892a --- /dev/null +++ b/token_bridge/package.json @@ -0,0 +1,8 @@ +{ + "name": "pickup-token-bridge", + "version": "1.0.0", + "private": true, + "dependencies": { + "jsdom": "^24.0.0" + } +} diff --git a/token_bridge/server.js b/token_bridge/server.js new file mode 100644 index 0000000..4671d08 --- /dev/null +++ b/token_bridge/server.js @@ -0,0 +1,117 @@ +/** + * Token Bridge — HTTP 服务器模式。 + * 在 jsdom 中运行 $_ts 安全框架,计算 c93l5Fya token。 + * + * 接口: + * GET /ping → {"ok":true} + * POST /init → body: {html, scripts, baseUrl} → {"ok":true, "nsd":...} + * POST /token → body: {method, path} → {"token":"..."} + */ + +const { JSDOM } = require('jsdom'); +const http = require('http'); + +let dom = null; +let window = null; +let _cdLen = 0; +let _nsd = null; + +function initialize(html, scripts, baseUrl) { + dom = new JSDOM(html, { + url: baseUrl, + runScripts: 'dangerously', + pretendToBeVisual: true, + }); + window = dom.window; + + _cdLen = (window.$_ts && window.$_ts.cd) ? window.$_ts.cd.length : 0; + _nsd = (window.$_ts && window.$_ts.nsd) ? window.$_ts.nsd : null; + + // 拦截 XMLHttpRequest.prototype.open —— security JS 会包装它 + const OriginalXHR = window.XMLHttpRequest; + OriginalXHR.prototype.open = function (method, url) { + this.__ts_method = method; + this.__ts_url = url; + }; + + // 注入 security JS + for (const script of scripts) { + try { + const el = window.document.createElement('script'); + el.textContent = script.content; + window.document.head.appendChild(el); + } catch (_) {} + } + + // 验证 + const testXhr = new OriginalXHR(); + testXhr.open('GET', '/_ts_test_'); + const captured = testXhr.__ts_url || ''; + + if (!captured.includes('c93l5Fya=')) { + throw new Error( + `$_ts init failed: cd_len=${_cdLen}, nsd=${_nsd}, captured=${captured.substring(0, 80)}` + ); + } + + return { ok: true, nsd: _nsd }; +} + +function computeToken(method, path) { + if (!window) throw new Error('not initialized'); + process.stderr.write(JSON.stringify({trace: 'computeToken', method, path}) + '\n'); + const OriginalXHR = window.XMLHttpRequest; + process.stderr.write(JSON.stringify({trace: 'XHR class', hasXHR: !!OriginalXHR}) + '\n'); + const xhr = new OriginalXHR(); + process.stderr.write(JSON.stringify({trace: 'xhr created'}) + '\n'); + xhr.open(method || 'GET', path); + process.stderr.write(JSON.stringify({trace: 'open called', url: (xhr.__ts_url||'').substring(0,100)}) + '\n'); + const url = xhr.__ts_url || ''; + const idx = url.indexOf('c93l5Fya='); + if (idx < 0) throw new Error(`Token not found for ${path}, url=${url.substring(0,80)}`); + return url.substring(idx + 'c93l5Fya='.length); +} + +// ─── HTTP Server ──────────────────────────────────────────── + +function readBody(req) { + return new Promise((resolve) => { + let data = ''; + req.on('data', c => data += c); + req.on('end', () => resolve(data)); + }); +} + +function json(res, data, status = 200) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); +} + +const server = http.createServer(async (req, res) => { + try { + if (req.method === 'GET' && req.url === '/ping') { + json(res, { ok: true }); + } else if (req.method === 'POST' && req.url === '/init') { + const body = await readBody(req); + const { html, scripts, baseUrl } = JSON.parse(body); + const result = initialize(html, scripts, baseUrl); + json(res, result); + } else if (req.method === 'POST' && req.url === '/token') { + const body = await readBody(req); + const { method, path } = JSON.parse(body); + const token = computeToken(method, path); + json(res, { token }); + } else { + json(res, { error: 'not found' }, 404); + } + } catch (e) { + process.stderr.write(JSON.stringify({ error: e.message }) + '\n'); + json(res, { error: e.message }, 500); + } +}); + +const port = process.argv[2] || 0; +server.listen(port, '127.0.0.1', () => { + const addr = server.address(); + process.stderr.write(JSON.stringify({ ready: true, port: addr.port }) + '\n'); +}); diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1d950b2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,286 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] +name = "pickup-auto" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "openpyxl" }, + { name = "pyinstaller" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "pyinstaller", specifier = ">=6.20.0" }, + { name = "requests", specifier = ">=2.31.0" }, +] + +[[package]] +name = "pyinstaller" +version = "6.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/60/d03d52e6690d4e9caf333dcd14550cde634ce6c118b3bc8fa3112c3186fd/pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a", size = 4048728, upload-time = "2026-04-22T20:59:36.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/e4/e228d6d1bbb7fd62dc660a8fb202a583b023d3a3624ca95d1a9290ee4d6a/pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e", size = 1047642, upload-time = "2026-04-22T20:58:32.006Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bd/afb631bcb3f9040efebd4f6d067f0828b51710818f69fb41a2d4b7787f52/pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712", size = 742494, upload-time = "2026-04-22T20:58:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/76/08/0729a5bac14754150e5d83b39d87d842eb42b0bffcaa03dbad6252e23a39/pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941", size = 754191, upload-time = "2026-04-22T20:58:40.603Z" }, + { url = "https://files.pythonhosted.org/packages/e6/82/bc0ee4c7b97db1958eb651e0da9fb1e672e5ae53ca8867fd97701de52906/pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e", size = 751902, upload-time = "2026-04-22T20:58:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/770002d6aaa54173881cb2c49bb195ba67b97bf39bac1cdf320f28401629/pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59", size = 748634, upload-time = "2026-04-22T20:58:48.579Z" }, + { url = "https://files.pythonhosted.org/packages/fe/db/68ba1fccb71278b2124fb90b37b7c8c0bc4c1173fba45b94466df3d9cb7f/pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b", size = 748490, upload-time = "2026-04-22T20:58:52.749Z" }, + { url = "https://files.pythonhosted.org/packages/03/0f/ac77ffa996a56be3d5c8f85734a007f8347240691657f9704e7de2527fa3/pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7", size = 747650, upload-time = "2026-04-22T20:58:57.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/56/1ee91c3a2bc10ca1f36da10a6fd55ff7efc4dec367171eb25992a827874f/pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9", size = 747413, upload-time = "2026-04-22T20:59:01.174Z" }, + { url = "https://files.pythonhosted.org/packages/d7/55/ae264339996953c4cdf9d89d916a0a8fa26a83cf917a742fff8b9d5f3fe8/pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c", size = 1331584, upload-time = "2026-04-22T20:59:07.201Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/300f57578882cce259bfb5ae56fda3b69caa3fe9df40a176c719920ea6e2/pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a", size = 1391851, upload-time = "2026-04-22T20:59:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ea/b2f8e1642aecda78c0b75c7321f708e49e10bb3c00dd4f148c40761a1527/pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2", size = 1332259, upload-time = "2026-04-22T20:59:20.509Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/67/f4452d68793fb15beba4f19ef39a38a8822f0da7452b503c400d5a21f5c1/pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08", size = 173689, upload-time = "2026-05-04T22:36:55.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/5c/fd465d11da4d12b50d7eb5d2ee2ceb780d8d049dbb489f3828d131e387af/pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9", size = 457314, upload-time = "2026-05-04T22:36:53.598Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "requests" +version = "2.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]