Author SHA1 Message Date
yeuimu 5f4e1ffa1f feat: 快递100上门取件集成(SDS运单自动生成+面单回传)
- api/kuaidi100: 上门取件API客户端(bOrder/price/cancel/detail,MD5签名封装)
- api/sds: 变体克重(children接口,24h缓存)/面单PDF上传/挂包裹/carriageNoStatus筛选
- flows/sds/waybill_autogen: 每10分钟遍历运单待生成订单(factoryIds+carriageNoStatus+orderStatusCode)
  自动下极兔(returnType=20+tempid出面单),面单回调转PDF回传SDS;
  幂等跳过已有运单;面单超时不自动取消(记人工跟进);schedule 默认 active:false
- scripts: 手动E2E测试脚本 + 回调接收服务(验签MD5(param+salt)+JSONL落盘,配frpc 18090穿透)
- 依赖 +pillow(面单PNG转PDF)
2026-11-03 08:02:39 +00:00
inkreach d60a99ab4e Merge pull request 'feat: RIIN 商品拉取改用 goods/list 批量接口(200/批串行)替换逐单 order/goods' (#14) from fix/riin-goods-batch-list into main
Reviewed-on: #14
2026-10-31 10:23:48 +00:00
inkreach 40288c2cd6 Merge pull request 'fix: EVRI 面单 OCR 单号入库前去掉 '-'' (#13) from develop into main
Reviewed-on: #13
2026-10-30 01:53:01 +00:00
inkreach a7fd330622 Merge pull request 'fix: order/goods 限速收紧至峰值<3次/秒(340ms+0~160ms随机间隔)' (#12) from inkreach/order-goods-throttle into main
Reviewed-on: #12
2026-10-23 11:14:11 +00:00
rom 0baac665da Merge pull request 'Develop' (#11) from develop into main
Reviewed-on: #11
2026-10-21 09:50:24 +00:00
inkreach e6fdda0b3f Merge pull request 'feat: OCR 新增支持波兰(PL,版式 pables_dpd/pables_inpost)' (#10) from inkreach/ocr-pl into main
Reviewed-on: #10
2026-10-15 07:00:30 +00:00
inkreach e4885803df Merge pull request 'fix: 写库重试 3→5 次、退避拉长至 1/3/7/15/30s' (#9) from inkreach/harden-db-write-retry into main
Reviewed-on: #9
2026-10-15 07:00:22 +00:00
10 changed files with 1184 additions and 4 deletions
+8
View File
@@ -0,0 +1,8 @@
"""快递100 上门取件API(线上支付) 客户端。
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
"""
from api.kuaidi100.client import Kuaidi100ApiError, Kuaidi100Client
__all__ = ["Kuaidi100Client", "Kuaidi100ApiError"]
+160
View File
@@ -0,0 +1,160 @@
"""快递100「上门取件API(线上支付)」REST 客户端(httpx + pydantic 思路,纯客户端层,禁止 import prefect)。
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
鉴权与签名:
- 公共 body 参数 method/key/sign/t/param(Content-Type: application/x-www-form-urlencoded);
- sign = MD5(param + t + key + secret) 转 32 位大写,secret 在企业管理后台获取;
- 凭据(key/secret/customer/tempid)由调用方(flow 层)从 Secret Block 读取后注入,本层不落盘。
实测注意(勿踩):
- taskId/orderId 回传取消接口时必须原样保持类型(orderId 是字符串数字);
- cancelMsg 含"测试"字样会被后端判"订单信息错误",用固定文案"暂时不寄件了";
- returnType=20 出面单图片短链,经 callBackUrl 回调推送(status=200),tempid 必填。
"""
from __future__ import annotations
import hashlib
import json
import time
from typing import Any
import httpx
PROD_BASE_URL = "https://poll.kuaidi100.com/order/borderapi.do"
TEST_BASE_URL = "http://e-test.kuaidilab.com/api/order/borderapi.do"
CANCEL_MSG = "暂时不寄件了"
class Kuaidi100ApiError(RuntimeError):
"""快递100 业务失败(result != true 或 returnCode != 200)。"""
class Kuaidi100Client:
"""寄件下单(bOrder)/查价(price)/取消(cancel)/订单详情(detail)。"""
def __init__(
self,
key: str,
secret: str,
*,
base_url: str = PROD_BASE_URL,
customer: str | None = None,
timeout: float = 30.0,
) -> None:
self._key = key
self._secret = secret
self._customer = customer
self._base_url = base_url
self._http = httpx.Client(timeout=timeout)
# ----- 公共调用 -----
@staticmethod
def make_sign(param: str, t: str, key: str, secret: str) -> str:
return hashlib.md5(f"{param}{t}{key}{secret}".encode("utf-8")).hexdigest().upper()
def call(self, method: str, param: dict[str, Any]) -> dict[str, Any]:
"""POST 公共表单,业务失败抛 Kuaidi100ApiError,成功返回整个响应 JSON。"""
param_str = json.dumps(param, ensure_ascii=False, separators=(",", ":"))
t = str(int(time.time() * 1000))
body = {
"method": method,
"key": self._key,
"sign": self.make_sign(param_str, t, self._key, self._secret),
"t": t,
"param": param_str,
}
resp = self._http.post(self._base_url, data=body)
resp.raise_for_status()
data = resp.json()
if data.get("result") is not True or str(data.get("returnCode")) != "200":
raise Kuaidi100ApiError(f"[{method}] {json.dumps(data, ensure_ascii=False)[:500]}")
return data
# ----- 业务接口 -----
def price(
self,
kuaidi_com: str,
send_addr: str,
rec_addr: str,
weight: str = "1",
service_type: str | None = None,
) -> dict[str, Any]:
"""查价(只读)。返回 data:defPrice/defFirstPrice/price/overPricePerKg/serviceType 等。"""
param: dict[str, Any] = {
"kuaidiCom": kuaidi_com,
"sendManPrintAddr": send_addr,
"recManPrintAddr": rec_addr,
"weight": weight,
}
if service_type:
param["serviceType"] = service_type
return self.call("price", param).get("data") or {}
def border(
self,
*,
kuaidicom: str,
rec_man_name: str,
rec_man_mobile: str,
rec_man_print_addr: str,
send_man_name: str,
send_man_mobile: str,
send_man_print_addr: str,
callback_url: str,
cargo: str | None = None,
weight: str | None = None,
payment: str = "SHIPPER",
remark: str | None = None,
salt: str | None = None,
return_type: str | None = None,
tempid: str | None = None,
print_callback_url: str | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""寄件下单。returnType=20 + tempid 时面单短链经回调(status=200)推送。
返回 data:taskId/orderId/kuaidinum/pollToken/appointGotStartTime 等,
taskId/orderId 后续回传必须保持原类型。
"""
param: dict[str, Any] = {
"kuaidicom": kuaidicom,
"recManName": rec_man_name,
"recManMobile": rec_man_mobile,
"recManPrintAddr": rec_man_print_addr,
"sendManName": send_man_name,
"sendManMobile": send_man_mobile,
"sendManPrintAddr": send_man_print_addr,
"callBackUrl": callback_url,
"payment": payment,
}
for key, value in (
("cargo", cargo), ("weight", weight), ("remark", remark), ("salt", salt),
("returnType", return_type), ("tempid", tempid), ("printCallBackUrl", print_callback_url),
):
if value is not None:
param[key] = value
if extra:
param.update(extra)
return self.call("bOrder", param).get("data") or {}
def cancel(self, task_id: Any, order_id: Any, cancel_msg: str = CANCEL_MSG) -> None:
"""取消订单(取件前)。taskId/orderId 原样回传下单返回值。"""
self.call("cancel", {"taskId": task_id, "orderId": order_id, "cancelMsg": cancel_msg})
def detail(self, task_id: Any) -> dict[str, Any]:
"""订单详情(只读):status/kuaidiNum/payStatus/preWeight 等。"""
return self.call("detail", {"taskId": task_id}).get("data") or {}
def close(self) -> None:
self._http.close()
def __enter__(self) -> "Kuaidi100Client":
return self
def __exit__(self, *exc: Any) -> None:
self.close()
+97
View File
@@ -23,6 +23,7 @@ from api.sds.models import (
OrderQueryParams,
OrderStatistics,
OrderStatus,
ProductChildrenResponse,
ProductListRequest,
ProductListResponse,
)
@@ -30,6 +31,7 @@ from api.sds.models import (
BASE_URL = "https://pod-api.sdspod.com"
COMPANY_NO = "印美达"
KEYWORDS_BATCH_SIZE = 200
PRODUCT_CACHE_TTL_SECONDS = 24 * 3600 # 产品变体(克重等)是主数据,长缓存
FILTERS_FIELDS = (
"factoryList,merchantList,logisticsList,earlyWarningList,"
"issuingBayAreaList,logisticsSourceList,extendStatusList"
@@ -64,6 +66,7 @@ class SdsMngClient(TokenHttpClient):
self._username = username
self._password = password
self._cache: TTLCache[Any] = TTLCache(cache_ttl_seconds)
self._product_cache: TTLCache[Any] = TTLCache(PRODUCT_CACHE_TTL_SECONDS)
self._concurrency = concurrency
# ----- 认证 -----
@@ -129,6 +132,8 @@ class SdsMngClient(TokenHttpClient):
query["earlyWarningType"] = params.early_warning_type
if params.item_num_type is not None:
query["itemNumType"] = params.item_num_type
if params.carriage_no_status is not None:
query["carriageNoStatus"] = params.carriage_no_status
return query
@staticmethod
@@ -297,6 +302,98 @@ class SdsMngClient(TokenHttpClient):
merged.extend(r.records)
return ProductListResponse(total_count=first.total_count, records=merged)
# ----- 产品变体/克重(主数据,24h 缓存) -----
def get_product_children(self, product_id: int) -> ProductChildrenResponse:
"""产品变体列表(含每尺码克重 weight 克/件、包装盒尺寸)。
GET /pod/product/{id}/children;变体克重是主数据,缓存 PRODUCT_CACHE_TTL_SECONDS。
"""
key = f"children:{product_id}"
cached = self._product_cache.get(key)
if cached is not None:
return cached
data = self.get(f"/pod/product/{product_id}/children", params={"t": int(time.time() * 1000)})
result = ProductChildrenResponse.model_validate(data)
self._product_cache.set(key, result)
return result
def get_variant_weight_map(self, product_id: int) -> dict[int, float]:
"""{变体 id: 克重}。订单行的 product_id 即变体 id,可直接查。"""
return {
c.id: c.weight
for c in self.get_product_children(product_id).records
if c.weight is not None
}
def resolve_product_id(self, code: str) -> int | None:
"""产品编码(父 sku,如 RB9327005) -> 产品 id(keyword 精确搜索,长缓存)。"""
key = f"pid:{code}"
cached = self._product_cache.get(key)
if cached is not None:
return cached or None
resp = self.get_products(ProductListRequest(keyword=code, size=1))
pid = resp.records[0].id if resp.records else 0
self._product_cache.set(key, pid)
return pid or None
def get_order_weight_grams(self, order: Any) -> tuple[float | None, list[str]]:
"""按变体克重汇总订单克重。返回 (总克重, 未匹配行描述);None 时调用方需回退。"""
total, missed, maps = 0.0, [], {}
for it in order.items or []:
supply = getattr(it, "productSupply", None) or {}
parent_id = supply.get("productParentId")
if parent_id is None or it.product_id is None:
missed.append(f"行{it.no or '?'} 缺 productParentId/product_id")
continue
if parent_id not in maps:
maps[parent_id] = self.get_variant_weight_map(parent_id)
weight = maps[parent_id].get(it.product_id)
if weight is None:
missed.append(f"行{it.no or it.product_id} 变体无克重字段")
continue
total += weight * (it.num or 1)
return (total or None), missed
# ----- 面单文件/包裹(运单回传) -----
def _send_with_token(
self,
method: str,
url: str,
*,
files: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
"""基类 _send 未覆盖的请求形态(multipart 上传 / PUT),带 token + 401 重登一次。"""
for attempt in (1, 2):
headers = {self._token_header: self._ensure_token()}
resp = self._http.request(method, url, files=files, json=json_body, params=params, headers=headers)
if resp.status_code == 401 and attempt == 1:
self._clear_token()
continue
resp.raise_for_status()
return resp.json() if resp.content else {}
return {}
def upload_waybill_pdf(self, filename: str, pdf_bytes: bytes) -> dict[str, Any]:
"""上传面单 PDF,返回含 CDN url 的文件信息。POST /files/pdf/logisticsWaybill。"""
files = {"file": (filename, pdf_bytes, "application/pdf")}
return self._send_with_token("POST", "/files/pdf/logisticsWaybill", files=files)
def attach_parcel_label(self, order_id: Any, parcel_id: Any, order_no: str, label_pdf_url: str) -> None:
"""把面单挂到订单包裹(覆盖语义)。PUT /orders/{orderId}/logistic/parcel/{parcelId}。
orderNo 传该包裹运单号(原样保留或回写新单号);labelPdf 为 upload_waybill_pdf 的 url。
"""
self._send_with_token(
"PUT",
f"/orders/{order_id}/logistic/parcel/{parcel_id}",
json_body={"orderNo": order_no, "labelPdf": label_pdf_url},
params={"t": int(time.time() * 1000)},
)
# ----- 物流 -----
def get_logistics_areas(self) -> IssuingBayAreaResponse:
+21
View File
@@ -219,6 +219,26 @@ class ProductListResponse(_SdsModel):
records: list[Product] = Field(default=[], alias="list")
class ProductVariant(_SdsModel):
"""产品变体(/pod/product/{id}/children):颜色/尺码/克重/包装盒尺寸。"""
id: int
parent_id: int | None = Field(default=None, alias="parentId")
sku: str | None = None
size: str | None = None
color_name: str | None = Field(default=None, alias="colorName")
color_code: str | None = Field(default=None, alias="colorCode")
status: int | None = None
weight: float | None = None # 克/件
box_length: float | None = Field(default=None, alias="boxLength")
box_width: float | None = Field(default=None, alias="boxWidth")
box_height: float | None = Field(default=None, alias="boxHeight")
class ProductChildrenResponse(_SdsModel):
records: list[ProductVariant] = Field(default=[], alias="list")
# ----- 物流域(混用 snake_case,显式 alias)-----
@@ -318,6 +338,7 @@ class OrderPageRequest(BaseModel):
extend_status: str | None = None
early_warning_type: str | None = None
item_num_type: str | None = None
carriage_no_status: str | None = None # 运单号状态筛选,如 generate=运单待生成
limit: int | None = None
all: bool | None = None
+241
View File
@@ -0,0 +1,241 @@
"""SDS 国内工厂运单自动生成:遍历「运单待生成」订单 → 自动下极兔 → 面单回传 SDS。
筛选参数: carriageNoStatus=generate(运单待生成) + factoryIds=1161(印美达国内工厂)
+ orderStatusCode=2(订单状态)。
链路: SDS 订单 → 变体克重(children 接口,客户端 24h 缓存)
→ 快递100 bOrder(极兔,returnType=20+tempid 出面单,回调走 frp 公网地址)
→ 回调服务 /list 轮询取面单短链(status=200)
→ PNG 转 PDF → SDS /files/pdf/logisticsWaybill 上传 → PUT 挂包裹(orderNo=新运单号)。
幂等与安全:
- 已有运单号的包裹跳过(10 分钟轮询天然去重);
- 面单等待超时只记录待人工跟进,不自动取消(快递员已接单时取消会扣其绩效);
- max_orders 限制单轮下单量(首轮积压保护);
- 改动必须先在测试环境/本地模拟验证,再真实下单(运营约定);
- ⚠️ 真实下单 flow:prefect.yaml 中本 deployment 的 schedule 默认 active:false,
上线前人工核对后改 true 并 `uv run prefect deploy --all` 才会开始调度。
对应 sds-waybill-autogen。
"""
from __future__ import annotations
import io
import json
import time
from typing import Any
import httpx
from PIL import Image
from prefect import flow
from prefect.blocks.system import Secret
from api.kuaidi100 import Kuaidi100Client
from api.sds.models import OrderPageRequest
from flows.shared import create_sds_client
# 寄件人固定为广州发货仓联系人
SEND_MAN_NAME = "张文风"
SEND_MAN_MOBILE = "18320690872"
SEND_MAN_ADDR = "广东省广州市番禺区南村镇新基村新盈科技园3栋2楼"
CALLBACK_BASE = "http://8.138.172.239:18090/kuaidi100" # 回调服务(frp 公网): /order 收推送, /list 查历史
CALLBACK_SALT = "inkreach-test" # 须与回调服务 kuaidi100-callback.service 的 --salt 一致
LABEL_POLL_INTERVAL_SECONDS = 5
CARGO_DEFAULT = "日用品"
def _load_secret(name: str) -> dict[str, str]:
value = Secret.load(name).get()
return json.loads(value) if isinstance(value, str) else value
def _join_addr(province: str | None, city: str | None, detail: str | None) -> str:
detail = detail or ""
prefix = ""
for part in (province or "", city or ""):
if part and part not in detail and part not in prefix:
prefix += part
return prefix + detail
def _parcel_info(order: Any) -> tuple[Any, str]:
"""取首个包裹 (parcel_id, 当前运单号,可能为空)。多包裹订单只处理包裹1并提示。"""
parcels = order.parcel_list or []
if len(parcels) > 1:
print(f"[autogen] 订单 {order.no} 有 {len(parcels)} 个包裹,仅处理包裹1")
if not parcels:
return None, ""
parcel = parcels[0] or {}
carriage = (parcel.get("logistics") or {}).get("carriage") or {}
return parcel.get("id"), str(carriage.get("no") or "")
def _wait_label_url(order_id: Any, callback_base: str, timeout_seconds: int) -> str | None:
"""轮询回调服务 /list,取该订单 status=200 回调里的面单短链。"""
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
try:
items = httpx.get(f"{callback_base}/list", params={"n": 50}, timeout=10).json().get("items") or []
for item in items:
param = item.get("param")
data = (param.get("data") or {}) if isinstance(param, dict) else {}
if str(data.get("orderId")) == str(order_id) and data.get("status") == 200 and data.get("label"):
return str(data["label"])
except Exception as err: # noqa: BLE001
print(f"[autogen] 回调服务查询失败(继续等待): {err}")
time.sleep(LABEL_POLL_INTERVAL_SECONDS)
return None
def _label_to_pdf(label_url: str) -> bytes:
resp = httpx.get(label_url, timeout=60, follow_redirects=True)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content)).convert("RGB")
buf = io.BytesIO()
img.save(buf, "PDF", resolution=150.0)
return buf.getvalue()
def _process_one(
sds: Any,
kd: Kuaidi100Client,
order: Any,
*,
courier: str,
tempid: str | None,
callback_base: str,
label_wait_seconds: int,
) -> dict[str, Any]:
"""单订单全链路:下单 → 等面单 → 回传。失败尽力取消快递单,返回结果记录。"""
order_no = order.no or order.id
record: dict[str, Any] = {"orderNo": order_no, "result": "fail"}
parcel_id, current_no = _parcel_info(order)
if not parcel_id:
record["error"] = "订单无包裹"
return record
if current_no:
record.update(result="skip", waybill=current_no, reason="已有运单号")
return record
addr = order.address
if not addr or not addr.consignee or not addr.mobile_phone:
record["error"] = "订单缺收件人信息"
return record
grams, missed = sds.get_order_weight_grams(order)
weight = f"{grams / 1000:.2f}" if grams else "1"
if missed:
print(f"[autogen] 订单 {order_no} 克重未完全匹配({missed}),申报重量 {weight}kg")
data = kd.border(
kuaidicom=courier,
rec_man_name=addr.consignee,
rec_man_mobile=addr.mobile_phone,
rec_man_print_addr=_join_addr(addr.province, addr.city, addr.detail),
send_man_name=SEND_MAN_NAME,
send_man_mobile=SEND_MAN_MOBILE,
send_man_print_addr=SEND_MAN_ADDR,
callback_url=f"{callback_base}/order",
print_callback_url=f"{callback_base}/order",
cargo=(order.items[0].product_name if order.items else None) or CARGO_DEFAULT,
weight=weight,
remark=f"SDS-{order_no} autogen",
salt=CALLBACK_SALT,
return_type="20",
tempid=tempid,
extra={"op": 0, "resultv2": "0"},
)
waybill = str(data.get("kuaidinum") or "")
record.update(kuaidiOrderId=data.get("orderId"), waybill=waybill)
print(f"[autogen] 订单 {order_no} 下单成功 {waybill},等待面单回调…")
label_url = _wait_label_url(data.get("orderId"), callback_base, label_wait_seconds)
if not label_url:
# 不自动取消:快递员可能已接单,取消会扣其绩效;记录待人工跟进(后台补打面单或人工取消)。
record["error"] = f"面单回调等待超时({label_wait_seconds}s),需人工跟进"
record["manualFollowUp"] = True
print(f"[autogen] 订单 {order_no} 面单超时,保留快递单 {waybill} 待人工处理(不自动取消)")
return record
pdf_bytes = _label_to_pdf(label_url)
upload = sds.upload_waybill_pdf(f"{order_no}.pdf", pdf_bytes)
sds.attach_parcel_label(order.id, parcel_id, waybill, upload["url"])
record.update(result="ok", labelPdf=upload["url"], labelImage=label_url, weightKg=weight)
print(f"[autogen] 订单 {order_no} 完成: {waybill} → {upload['url']}")
return record
@flow(name="sds-waybill-autogen", log_prints=True)
def waybill_autogen(
enabled: bool = True,
courier: str = "jtexpress",
factory_ids: list[int] | None = None,
order_status_code: str = "2",
max_orders: int = 20,
label_wait_seconds: int = 120,
kuaidi100_block: str = "kuaidi100-prod",
callback_base: str = CALLBACK_BASE,
) -> dict[str, Any]:
"""运单待生成订单 → 极兔下单 + 面单回传。prefect.yaml 默认 schedule active:false。"""
factory_ids = factory_ids or [1161]
if not enabled:
print("[autogen] enabled=false,本轮跳过(不查询不下单)")
return {"enabled": False, "processed": 0}
cred = _load_secret(kuaidi100_block)
if not cred.get("tempid"):
raise ValueError(f"Secret Block {kuaidi100_block} 缺 tempid(面单模板编码),无法出面单")
sds = create_sds_client()
kd = Kuaidi100Client(cred["key"], cred["secret"], customer=cred.get("customer"))
try:
resp = sds.get_orders(
OrderPageRequest(
factory_ids=factory_ids,
carriage_no_status="generate",
order_status_code=order_status_code,
size=min(max_orders, 200),
page=1,
)
)
print(
f"[autogen] 待处理订单 {resp.total_count} 条"
f"(本轮上限 {max_orders},快递={courier},orderStatusCode={order_status_code})"
)
records = []
for order in resp.records[:max_orders]:
try:
records.append(
_process_one(
sds, kd, order,
courier=courier, tempid=cred["tempid"],
callback_base=callback_base, label_wait_seconds=label_wait_seconds,
)
)
except Exception as err: # noqa: BLE001
print(f"[autogen] 订单 {order.no} 处理异常: {err}")
records.append({"orderNo": order.no, "result": "fail", "error": str(err)[:300]})
finally:
kd.close()
sds.close()
summary = {
"enabled": True,
"pendingTotal": resp.total_count,
"processed": len(records),
"ok": sum(1 for r in records if r["result"] == "ok"),
"skip": sum(1 for r in records if r["result"] == "skip"),
"fail": sum(1 for r in records if r["result"] == "fail"),
"records": records,
}
print(
f"[autogen] 本轮完成: 成功 {summary['ok']} / 跳过 {summary['skip']} / 失败 {summary['fail']}"
f"(待生成共 {resp.total_count})"
)
return summary
if __name__ == "__main__":
# 本地直接运行默认不下单;要真实执行请带 enabled=True 显式运行或部署后由调度触发。
waybill_autogen(enabled=False)
+8
View File
@@ -82,6 +82,14 @@ deployments:
timezone: Asia/Shanghai
work_pool:
name: inkreach-pool
- name: sds-waybill-autogen
entrypoint: flows/sds/waybill_autogen.py:waybill_autogen
schedules:
- cron: '*/10 * * * *'
timezone: Asia/Shanghai
active: false # 暂停:真实下单 flow(极兔+面单回传)。链路人工核对后改 true 并 `uv run prefect deploy --all` 生效
work_pool:
name: inkreach-pool
- name: platform-order-riin
entrypoint: flows/platform_order/riin.py:riin_platform_sync
schedules:
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"psutil>=7.2.2",
"requests>=2.34.2",
"aiohttp>=3.14.3",
"pillow>=13.0.0",
]
[build-system]
+159
View File
@@ -0,0 +1,159 @@
"""快递100 上门取件API 回调接收服务(本机常驻,经 frpc 暴露公网)。
接收两类推送并验签落盘(MD5(param + salt)):
- POST /kuaidi100/order 下单状态回调(callBackUrl)与面单短链/打印回调(printCallBackUrl)
- POST /kuaidi100/poll 轨迹推送(pollCallBackUrl)
应答格式必须是 {"result":true,"returnCode":"200","message":"成功"},
否则快递100会以 30 分钟间隔重推 3 次。
查看:
- GET / 状态与最近回调摘要
- GET /list?n=20 最近 n 条回调明细(JSON)
落盘: <repo>/data/kuaidi100/callbacks.jsonl,每行 {ts,path,verified,taskId,param}
用法: uv run python scripts/kuaidi100_callback_server.py [--port 9802] [--salt inkreach-test]
salt 需与下单 param.salt 一致;也可用环境变量 KUAIDI100_CALLBACK_SALT 覆盖。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
REPO_ROOT = Path(__file__).resolve().parent.parent
LOG_FILE = REPO_ROOT / "data" / "kuaidi100" / "callbacks.jsonl"
MAX_BODY = 1 << 20 # 1MB 上限,防异常大包
_state = {"started_at": time.time(), "received": 0, "verified_ok": 0}
def _append(record: dict) -> None:
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _recent(n: int = 20) -> list[dict]:
if not LOG_FILE.exists():
return []
try:
lines = LOG_FILE.read_text(encoding="utf-8").splitlines()
except OSError:
return []
out = []
for line in lines[-n:]:
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue
return out
class Handler(BaseHTTPRequestHandler):
salt: str = os.environ.get("KUAIDI100_CALLBACK_SALT", "inkreach-test")
def log_message(self, fmt: str, *args) -> None: # 静默默认访问日志,落盘代替
pass
# ----- POST 回调 -----
def do_POST(self) -> None:
path = urlparse(self.path).path
if not path.startswith("/kuaidi100/"):
self._json(404, {"result": False, "returnCode": "404", "message": "not found"})
return
length = min(int(self.headers.get("Content-Length") or 0), MAX_BODY)
body = self.rfile.read(length).decode("utf-8", errors="replace")
form = parse_qs(body, keep_blank_values=True)
param_raw = (form.get("param") or [""])[0]
sign = (form.get("sign") or [""])[0].upper()
task_id = (form.get("taskId") or [""])[0]
expect = hashlib.md5(f"{param_raw}{self.salt}".encode("utf-8")).hexdigest().upper()
verified = bool(param_raw) and sign == expect
parsed: dict | None = None
if param_raw:
try:
parsed = json.loads(param_raw)
except json.JSONDecodeError:
parsed = None
_state["received"] += 1
_state["verified_ok"] += int(verified)
_append(
{
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
"path": path,
"verified": verified,
"sign": sign,
"taskId": task_id or None,
"param": parsed if parsed is not None else param_raw[:2000],
"client": self.client_address[0],
}
)
# 验签失败也先按成功应答(已落盘标记 verified=false),避免测试环境反复重推干扰;
# 生产可改为拒绝: {"result": false, ...}
self._json(200, {"result": True, "returnCode": "200", "message": "成功"})
# ----- GET 查看 -----
def do_GET(self) -> None:
url = urlparse(self.path)
# 兼容两种路径:直连 /list 与经 frp/反代的 /kuaidi100/list(与 POST 路由前缀对齐)
if url.path in ("/list", "/kuaidi100/list"):
qs = parse_qs(url.query)
n = min(int((qs.get("n") or ["20"])[0]), 200)
self._json(200, {"count": n, "items": list(reversed(_recent(n)))})
return
recent = _recent(5)
self._json(
200,
{
"service": "kuaidi100-callback",
"listen": "127.0.0.1 (via frpc)",
"started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(_state["started_at"])),
"uptime_s": int(time.time() - _state["started_at"]),
"received": _state["received"],
"verified_ok": _state["verified_ok"],
"log_file": str(LOG_FILE),
"recent": [
{"ts": r.get("ts"), "path": r.get("path"), "verified": r.get("verified"),
"status": (r.get("param") or {}).get("data", {}).get("status")
if isinstance(r.get("param"), dict) else None}
for r in recent
],
},
)
def _json(self, code: int, payload: dict) -> None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def main() -> None:
parser = argparse.ArgumentParser(description="快递100回调接收服务")
parser.add_argument("--port", type=int, default=9802)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--salt", default=Handler.salt, help="验签 salt,须与下单 param.salt 一致")
args = parser.parse_args()
Handler.salt = args.salt
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"[kuaidi100-callback] listening on http://{args.host}:{args.port} (salt={args.salt})")
print(f"[kuaidi100-callback] log -> {LOG_FILE}")
server.serve_forever()
if __name__ == "__main__":
main()
+407
View File
@@ -0,0 +1,407 @@
"""快递100「上门取件API(线上支付)」端到端测试脚本。
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
流程:
1. 从 SDS 取一单国内工厂订单(factoryIds=1161),提取收/寄件人地址(收件人=订单 address,寄件人=发货仓 issuing_bay)
2. 逐家查价(method=price),选最便宜的快递公司
3. 下单(method=bOrder)拿 taskId/orderId/kuaidinum
4. 取消订单(method=cancel)收尾,避免遗留测试单
鉴权: 公共参数 method/key/sign/t,param 为 JSON 字符串;
sign = MD5(param + t + key + secret) 转 32 位大写。
测试环境: http://e-test.kuaidilab.com/api/order/borderapi.do(测试 key/secret 需向快递100技术支持申请)
用法:
uv run python scripts/kuaidi100_pickup_test.py --key KEY --secret SECRET [--customer CUSTOMER]
# 或把 JSON {"key":"...","secret":"...","customer":"..."} 存进 Prefect Secret Block kuaidi100-test 后:
uv run python scripts/kuaidi100_pickup_test.py --from-secret
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import time
from pathlib import Path
from typing import Any
import httpx
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
TEST_BASE_URL = "http://e-test.kuaidilab.com/api/order/borderapi.do"
PROD_BASE_URL = "https://poll.kuaidi100.com/order/borderapi.do"
# 文档列出的本 API 支持的快递公司编码(小写)
SUPPORTED_COURIERS = [
"jd",
"debangkuaidi",
"shunfeng",
"jtexpress",
"yuantong",
"shentong",
"zhongtong",
"zhongtongkuaiyun",
"yunda",
"kuayue",
"shunfengkuaiyun",
"ems",
"yimidida",
"kuaidi100zhixuan",
]
COURIER_NAMES = {
"jd": "京东",
"debangkuaidi": "德邦快递",
"shunfeng": "顺丰速运",
"jtexpress": "极兔速递",
"yuantong": "圆通速递",
"shentong": "申通快递",
"zhongtong": "中通快递",
"zhongtongkuaiyun": "中通快运",
"yunda": "韵达快递",
"kuayue": "跨越速运",
"shunfengkuaiyun": "顺丰快运",
"ems": "EMS",
"yimidida": "壹米滴答",
"kuaidi100zhixuan": "快递100智选",
}
# ---------- 快递100 公共调用 ----------
def make_sign(param: str, t: str, key: str, secret: str) -> str:
"""文档签名算法: MD5(param + t + key + secret) 转 32 位大写。"""
raw = f"{param}{t}{key}{secret}"
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
def call_api(
method: str,
param: dict[str, Any],
auth: dict[str, str],
base_url: str = TEST_BASE_URL,
timeout: float = 30.0,
) -> dict[str, Any]:
"""POST application/x-www-form-urlencoded,公共参数 method/key/sign/t/param。"""
param_str = json.dumps(param, ensure_ascii=False, separators=(",", ":"))
t = str(int(time.time() * 1000))
body = {
"method": method,
"key": auth["key"],
"sign": make_sign(param_str, t, auth["key"], auth["secret"]),
"t": t,
"param": param_str,
}
resp = httpx.post(base_url, data=body, timeout=timeout)
resp.raise_for_status()
return resp.json()
def check(resp: dict[str, Any], action: str) -> dict[str, Any]:
if resp.get("result") is not True or str(resp.get("returnCode")) != "200":
raise RuntimeError(f"[{action}] 失败: {json.dumps(resp, ensure_ascii=False)[:500]}")
print(f"[{action}] 成功: {resp.get('message')}")
return resp
# ---------- SDS 取单 ----------
def calc_order_weight_grams(client: Any, order: Any) -> tuple[float | None, list[str]]:
"""委托 SdsMngClient.get_order_weight_grams(children 变体克重,24h 缓存)。"""
return client.get_order_weight_grams(order)
def fetch_sds_domestic_order(client: Any | None = None) -> dict[str, str]:
"""取一单国内工厂(factoryIds=1161)订单,返回寄件/收件人字段+结构化克重。"""
if client is None:
from api import SdsMngClient
from prefect.blocks.system import Secret
value = Secret.load("sds-credentials").get()
cred = json.loads(value) if isinstance(value, str) else value
client = SdsMngClient(cred["username"], cred["password"])
from api.sds.models import OrderPageRequest
resp = client.get_orders(OrderPageRequest(factory_ids=[1161], size=1, page=1))
if not resp.records:
raise RuntimeError("SDS 未找到 factoryIds=1161 的订单")
order = resp.records[0]
addr = order.address
bay = order.issuing_bay
if not addr or not bay:
raise RuntimeError("订单缺少收件地址或发货仓信息")
send_print_addr = _join_addr(bay.province, bay.city, bay.detail)
rec_print_addr = _join_addr(addr.province, addr.city, addr.detail)
cargo = order.items[0].product_name or "日用品" if order.items else "日用品"
info = {
"order_no": order.no or order.id,
"send_name": bay.name or "印美达",
"send_addr": send_print_addr,
"rec_name": addr.consignee or "收件人",
"rec_mobile": addr.mobile_phone or "",
"rec_addr": rec_print_addr,
"cargo": cargo,
}
grams, missed = calc_order_weight_grams(client, order)
if grams:
info["weight_kg"] = f"{grams / 1000:.2f}"
print(f"[weight] 结构化克重合计 {round(grams)}g = {info['weight_kg']}kg")
else:
print(f"[weight] 未能取到结构化克重({'; '.join(missed) or '无商品行'}),下单重量回退为默认 1kg")
print(
f"[SDS] 选中订单 {info['order_no']}\n"
f" 寄件: {info['send_name']} / {info['send_addr']}\n"
f" 收件: {info['rec_name']} {info['rec_mobile']} / {info['rec_addr']}\n"
f" 物品: {info['cargo']}"
)
return info
# ---------- 业务步骤 ----------
def compare_prices(auth: dict[str, str], order_info: dict[str, str], base_url: str, weight: str) -> tuple[str, list[dict[str, Any]]]:
"""逐家查价,按 defPrice(默认总价)升序,返回 (最便宜编码, 全部结果)。"""
results: list[dict[str, Any]] = []
for com in SUPPORTED_COURIERS:
param = {
"kuaidiCom": com,
"sendManPrintAddr": order_info["send_addr"],
"recManPrintAddr": order_info["rec_addr"],
"weight": weight,
}
try:
resp = call_api("price", param, auth, base_url)
if resp.get("result") is True:
data = resp.get("data", {})
results.append({"com": com, **data})
print(
f"[price] {COURIER_NAMES.get(com, com):8s} 首重={data.get('defFirstPrice')} "
f"续重={data.get('defOverPrice')} 总价={data.get('defPrice')}"
)
else:
print(f"[price] {COURIER_NAMES.get(com, com):8s} 不支持: {resp.get('message')}")
except Exception as err: # noqa: BLE001
print(f"[price] {COURIER_NAMES.get(com, com):8s} 查询异常: {str(err)[:120]}")
time.sleep(0.3)
priced = [r for r in results if str(r.get("defPrice", "") not in ("", None)) and _to_float(r.get("defPrice")) is not None]
if not priced:
raise RuntimeError("没有任何快递公司返回有效价格")
priced.sort(key=lambda r: _to_float(r["defPrice"]))
best = priced[0]
print(
f"[price] 共 {len(priced)} 家报价,最便宜: {COURIER_NAMES.get(best['com'], best['com'])} "
f"({best['com']}) 总价 {best['defPrice']} 元"
)
return best["com"], results
def _join_addr(province: str | None, city: str | None, detail: str | None) -> str:
"""拼省+市+详细地址;detail 已含省/市名时跳过对应前缀,避免重复。"""
detail = detail or ""
prefix = ""
for part in (province or "", city or ""):
if part and part not in detail and part not in prefix:
prefix += part
return prefix + detail
def _to_float(v: Any) -> float | None:
try:
return float(v)
except (TypeError, ValueError):
return None
def place_order(
auth: dict[str, str], order_info: dict[str, str], base_url: str, kuaidicom: str, weight: str,
sender_mobile: str, callback_url: str, dry_run: bool = False,
) -> dict[str, Any]:
param = {
"kuaidicom": kuaidicom,
"recManName": order_info["rec_name"],
"recManMobile": order_info["rec_mobile"],
"recManPrintAddr": order_info["rec_addr"],
"sendManName": order_info["send_name"],
"sendManMobile": sender_mobile,
"sendManPrintAddr": order_info["send_addr"],
"callBackUrl": callback_url,
"cargo": order_info["cargo"],
"weight": weight,
"payment": "SHIPPER",
"remark": f"kuaidi100-e2e-test {order_info['order_no']}",
"salt": "inkreach-test",
"op": 0,
"resultv2": "0",
}
if kuaidicom in ("shunfeng", "shunfengkuaiyun"): # 文档要求顺丰必填预约取件时间窗(间隔≥1小时)
now = time.localtime()
start_h = (now.tm_hour + 1) % 24
param.update(
dayType="1",
pickupStartTime=f"{start_h:02d}:00",
pickupEndTime=f"{(start_h + 1) % 24:02d}:00",
)
if dry_run:
print("[bOrder dry-run] 以下为将提交的完整 param(未发送请求):")
print(json.dumps(param, ensure_ascii=False, indent=2))
return {}
resp = check(call_api("bOrder", param, auth, base_url), "bOrder 下单")
data = resp.get("data") or {}
print(
f"[bOrder] taskId={data.get('taskId')} orderId={data.get('orderId')} "
f"kuaidinum={data.get('kuaidinum')} pollToken={str(data.get('pollToken'))[:20]}"
)
return data
def query_detail(auth: dict[str, str], base_url: str, task_id: Any) -> dict[str, Any] | None:
"""订单详情查询(method=detail,只传 taskId),校验订单已正确落库。"""
try:
resp = check(call_api("detail", {"taskId": task_id}, auth, base_url), "detail 订单详情")
data = resp.get("data") or {}
print(
f"[detail] status={data.get('status')} kuaidinum={data.get('kuaidiNum')} "
f"serviceType={data.get('serviceType')} payStatus={data.get('payStatus')}"
)
return data
except Exception as err: # noqa: BLE001
print(f"[detail] 查询失败(不中断测试): {str(err)[:160]}")
return None
def cancel_order(auth: dict[str, str], base_url: str, task_id: Any, order_id: Any) -> None:
"""取消订单。taskId/orderId 原样回传;cancelMsg 用文档示例文案(实测含"测试"字样会被判订单信息错误)。"""
param = {"taskId": task_id, "orderId": order_id, "cancelMsg": "暂时不寄件了"}
check(call_api("cancel", param, auth, base_url), "cancel 取消")
def track_query(auth: dict[str, str], com: str, num: str, phone: str = "") -> dict[str, Any] | None:
"""实时轨迹查询(poll/query.do): sign=MD5(param+key+customer),需 customer 参数。"""
if not auth.get("customer"):
print("[track] 未提供 customer,跳过轨迹查询")
return None
url = base_query_url(auth)
param = json.dumps({"com": com, "num": num, "phone": phone}, ensure_ascii=False, separators=(",", ":"))
customer = auth["customer"]
sign = hashlib.md5(f"{param}{auth['key']}{customer}".encode()).hexdigest().upper()
resp = httpx.post(
url,
data={"customer": customer, "sign": sign, "param": param},
timeout=30,
).json()
print(f"[track] {json.dumps(resp, ensure_ascii=False)[:300]}")
return resp
def base_query_url(auth: dict[str, str]) -> str:
base = TEST_BASE_URL.replace("/order/borderapi.do", "")
return f"{base}/poll/query.do"
# ---------- 入口 ----------
def load_auth(args: argparse.Namespace) -> dict[str, str]:
if args.from_secret:
from prefect.blocks.system import Secret
value = Secret.load(args.secret_block).get()
auth = json.loads(value) if isinstance(value, str) else value
else:
auth = {"key": args.key or "", "secret": args.secret or "", "customer": args.customer or ""}
missing = [k for k in ("key", "secret") if not auth.get(k)]
if missing:
raise SystemExit(f"缺少快递100鉴权参数: {missing}。用 --key/--secret 或 --from-secret 提供。")
return auth
def main() -> None:
parser = argparse.ArgumentParser(description="快递100上门取件API端到端测试")
parser.add_argument("--key", help="企业版授权码 key")
parser.add_argument("--secret", help="签名密钥 secret")
parser.add_argument("--customer", default="", help="轨迹查询用 customer(可选)")
parser.add_argument("--from-secret", action="store_true", help="从 Prefect Secret Block 读鉴权")
parser.add_argument("--secret-block", default="kuaidi100-test", help="Secret Block 名(默认 kuaidi100-test)")
parser.add_argument("--base-url", default=TEST_BASE_URL, help=f"API 地址(默认测试环境 {TEST_BASE_URL})")
parser.add_argument("--courier", default="yuantong", help="快递编码(默认固定圆通 yuantong)")
parser.add_argument("--auto-cheapest", action="store_true", help="按比价结果自动选最便宜(默认用 --courier 固定值)")
parser.add_argument("--skip-price", action="store_true", help="跳过比价查询")
parser.add_argument("--weight", default=None, help="重量 KG;默认用 SDS 变体克重自动汇总,取不到回退 1")
parser.add_argument(
"--sender-name", default="张文风",
help="寄件人姓名(固定,覆盖 SDS 发货仓名)",
)
parser.add_argument(
"--sender-mobile", default="18320690872",
help="寄件人手机号(固定)",
)
parser.add_argument(
"--sender-addr", default="广东省广州市番禺区南村镇新基村新盈科技园3栋2楼",
help="寄件地址(固定,覆盖 SDS 发货仓地址)",
)
parser.add_argument("--dry-run", action="store_true", help="只构造并打印下单参数,不实际下单")
parser.add_argument(
"--callback-url",
default="http://8.138.172.239:18090/kuaidi100/order",
help="订单回调地址(默认走专属 frpc 实例 kuaidi100 穿透到本机回调服务)",
)
parser.add_argument("--keep-order", action="store_true", help="下单后不取消")
args = parser.parse_args()
auth = load_auth(args)
order_info = fetch_sds_domestic_order()
# 寄件人固定为广州仓联系人(命令行可覆盖),收件人保持 SDS 订单动态获取
order_info["send_name"] = args.sender_name
order_info["send_addr"] = args.sender_addr
print(f"[sender] 寄件人固定: {order_info['send_name']} {args.sender_mobile} / {order_info['send_addr']}")
weight = args.weight or order_info.get("weight_kg") or "1"
print(f"[weight] 下单申报重量: {weight}kg")
kuaidicom = args.courier
if not args.skip_price:
cheapest, _ = compare_prices(auth, order_info, args.base_url, weight) # 比价仅作参考
if args.auto_cheapest:
kuaidicom = cheapest
print(f"[price] --auto-cheapest 生效,切到最便宜: {COURIER_NAMES.get(cheapest, cheapest)} ({cheapest})")
print(f"[courier] 固定使用: {COURIER_NAMES.get(kuaidicom, kuaidicom)} ({kuaidicom})")
data = place_order(
auth, order_info, args.base_url, kuaidicom, weight,
args.sender_mobile, args.callback_url, dry_run=args.dry_run,
)
if args.dry_run:
print("[done] dry-run 结束,未下单 ✓")
return
task_id = data.get("taskId") # 保持原始类型回传,cancel 接口对类型敏感
order_id = data.get("orderId")
kuaidinum = str(data.get("kuaidinum") or "")
if kuaidinum:
track_query(auth, kuaidicom, kuaidinum, order_info["rec_mobile"])
if task_id:
query_detail(auth, args.base_url, task_id)
if not args.keep_order and task_id and order_id:
cancel_order(auth, args.base_url, task_id, order_id)
else:
print(f"[done] 保留测试单: taskId={task_id} orderId={order_id}")
print("[done] 端到端测试完成 ✓")
if __name__ == "__main__":
main()
Generated
+82 -4
View File
@@ -952,6 +952,7 @@ dependencies = [
{ name = "aiohttp" },
{ name = "httpx" },
{ name = "pandas" },
{ name = "pillow" },
{ name = "prefect" },
{ name = "prefect-sqlalchemy" },
{ name = "psutil" },
@@ -965,6 +966,7 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.14.3" },
{ name = "httpx" },
{ name = "pandas" },
{ name = "pillow", specifier = ">=13.0.0" },
{ name = "prefect", specifier = ">=3.4" },
{ name = "prefect-sqlalchemy" },
{ name = "psutil", specifier = ">=7.2.2" },
@@ -1483,8 +1485,8 @@ name = "pendulum"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "python_full_version < '3.13'" },
{ name = "python-dateutil" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" }
wheels = [
@@ -1521,6 +1523,82 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" },
]
[[package]]
name = "pillow"
version = "13.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" },
{ url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" },
{ url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" },
{ url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" },
{ url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" },
{ url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" },
{ url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" },
{ url = "https://files.pythonhosted.org/packages/82/e2/9132d38df320e8099e2642d9fa538f18a7dfebf68a875a31725dc2a115aa/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e98b4f91f99b24b1da72e10621ba181f7281922384a7403094debb4199de6c00", size = 4190620, upload-time = "2026-10-16T22:16:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/cb/be/436dac0996876f65ef1916ffb711c5ef4af2ebc57414b6f16642e07f6b47/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34fb3db1149c879343861831728c243c432d4a5afc8f1878ca7612ef8de2a033", size = 4290286, upload-time = "2026-10-16T22:16:04.861Z" },
{ url = "https://files.pythonhosted.org/packages/60/16/7c1007162e4169ea0d5bec47e2e0b2174f56c50cf5b8c02a1dc23e41037e/pillow-13.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:2d50500a4a3bc6469adcdb319cf05d688b08ca3bf0f22a05339f3b25b433a599", size = 3723169, upload-time = "2026-10-16T22:16:06.956Z" },
{ url = "https://files.pythonhosted.org/packages/08/bc/578ab2eaa04705a8978bd9be0fe15c3d43a8fc95097c4f7e39540e79e4fa/pillow-13.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef5fe6b7db3f501c56000ba8fe7c7e1a35ca1d45824fbeb95a67daa85cff4dbe", size = 5568469, upload-time = "2026-10-16T22:16:08.664Z" },
{ url = "https://files.pythonhosted.org/packages/98/c8/ccede3dcc4f274b80fb49199eed1ef34ba05566c8b71976b9d1f111da2e3/pillow-13.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f11a3c1358bb3c68012a7b1f41b223c3db9f7bfde90b481df028cfe1c3363313", size = 4852187, upload-time = "2026-10-16T22:16:10.693Z" },
{ url = "https://files.pythonhosted.org/packages/fb/3c/cd0770e96d20bfd990db024bcbd43897ff3e88da1f4782cf90ea231b1d4f/pillow-13.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b66ca0a4d25cc0200b381ce824c7ea090db61427315e73cfb05dbf1b35c369", size = 6515908, upload-time = "2026-10-16T22:16:12.896Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fe/f5cdaa2d1c4086e757430f368c271a58ee29cfe7378e7ae7b9d964015c25/pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f56499c1a74d2b862beb0c33b8016f99d095858455df9d21583bbbc1c15b582", size = 7142544, upload-time = "2026-10-16T22:16:14.844Z" },
{ url = "https://files.pythonhosted.org/packages/fb/b5/a54f6d44adbcd23737c8c12953f42be107e4cca64e247c0ca3a5b563061c/pillow-13.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2a14cd73d5e6c927f644b0110ee81f59c4ef506259ec9183904747f2caacc81", size = 6613097, upload-time = "2026-10-16T22:16:16.893Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b4/9e6c1207c21b2e91db0d15c16d30f3701ce5add1bd4da0e787761e66cb6e/pillow-13.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08b826a9bf1265c76de04a42061d61ea6b109418b7826f28ff1ecdfaa26c5502", size = 7224846, upload-time = "2026-10-16T22:16:19.195Z" },
{ url = "https://files.pythonhosted.org/packages/78/9f/d3c3cb0518f3dd1eee0ef501535f53d15597a53c5c4118d178086143397a/pillow-13.0.0-cp313-cp313-win32.whl", hash = "sha256:12b0fa15879fe5a9d9eebe66e4e2d74128a773b1a4448010c9c714f45e04b9d8", size = 6516013, upload-time = "2026-10-16T22:16:21.389Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ed/1b16fb66a37e1b9e41370b93b2c614c7da761a5b2a41dc333f7af4639962/pillow-13.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:9dda0788a9932776a75d8bcc020076edc308ce2508b51f6739a45e3158b3fc42", size = 7284777, upload-time = "2026-10-16T22:16:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/7d/8d02eba9f7ecc87f8e90f731e032cc4773802f2a843763afd0c66a535ff4/pillow-13.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:40a266371ece581e5de13b6635ef78d7487824af80561b6ca27694a88c19bc0f", size = 2698826, upload-time = "2026-10-16T22:16:25.382Z" },
{ url = "https://files.pythonhosted.org/packages/8e/45/fbad9c7f5e2f1745c373a434d46072bc84eddfefaa28b852425ac957fda6/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c", size = 4190721, upload-time = "2026-10-16T22:16:27.524Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bf/2074902a259a90692c5dbf132885d21671644cbbd3f690047516243a55db/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76", size = 4290236, upload-time = "2026-10-16T22:16:29.562Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a5/3d1bb93b8ea38d15d21356c1b8e9f50971ac592aaa248f7fb71739094c0e/pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981", size = 3723093, upload-time = "2026-10-16T22:16:31.249Z" },
{ url = "https://files.pythonhosted.org/packages/84/a1/be060135d85187b25fa8b2573786c0e45c62ae27527fff5257675eb97f4a/pillow-13.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4", size = 5572253, upload-time = "2026-10-16T22:16:33.103Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f4/0bf6f1ac9ba7ee2ffe9914aeb410a1035a0c1a3c42f122388d7fc950bc4b/pillow-13.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d", size = 4852269, upload-time = "2026-10-16T22:16:35.102Z" },
{ url = "https://files.pythonhosted.org/packages/83/16/b603274617b3e6728da3a0133d4fba1ea8d7fbe0c4ea5fef9cbffa398b8b/pillow-13.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6", size = 6516058, upload-time = "2026-10-16T22:16:37.228Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d", size = 7142574, upload-time = "2026-10-16T22:16:39.172Z" },
{ url = "https://files.pythonhosted.org/packages/b9/a9/62cf68587f910bc87c5951bcb6e9430c7643a65076adf5f18b4bf350e2bd/pillow-13.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c", size = 6614295, upload-time = "2026-10-16T22:16:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c9/9e9b48178649730299be0bafbf5f810a5b6977538f5a92d47e4ba5f5d608/pillow-13.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0", size = 7224841, upload-time = "2026-10-16T22:16:43.118Z" },
{ url = "https://files.pythonhosted.org/packages/8f/48/c44e91384b1b6c072a714a63d00608acc16a6a968735e478e4be4384e5ac/pillow-13.0.0-cp314-cp314-win32.whl", hash = "sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055", size = 6508694, upload-time = "2026-10-16T22:16:45.883Z" },
{ url = "https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a", size = 7281830, upload-time = "2026-10-16T22:16:47.725Z" },
{ url = "https://files.pythonhosted.org/packages/41/9f/e5b51f2ce8ef4563215334035403ce61a244f93a2e65c24a940131618f87/pillow-13.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4", size = 2695468, upload-time = "2026-10-16T22:16:49.515Z" },
{ url = "https://files.pythonhosted.org/packages/97/73/a53502f731f6c84805a4ce87a8881e86d07a65aee64728b900d112dc703e/pillow-13.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa", size = 5578196, upload-time = "2026-10-16T22:16:51.291Z" },
{ url = "https://files.pythonhosted.org/packages/21/50/7e949959a56b5559c78ef172b70fc6025706acb289f3f648a15329e4fa6f/pillow-13.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f", size = 4857276, upload-time = "2026-10-16T22:16:53.399Z" },
{ url = "https://files.pythonhosted.org/packages/c9/52/43ded0ac08e77e39d17bd6d894162572201b5e8fdc4a022b14c55a1960a4/pillow-13.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1", size = 6607008, upload-time = "2026-10-16T22:16:55.525Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2e/4a57ed216d5bf877387cba4d44c02aa87abe51ad2e06404aa6b597c4da39/pillow-13.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683", size = 7224281, upload-time = "2026-10-16T22:16:57.738Z" },
{ url = "https://files.pythonhosted.org/packages/5c/3d/2929b248e135752d63aaeabf53f6de797e905b7b6263f9c1e2adade20aba/pillow-13.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058", size = 6695600, upload-time = "2026-10-16T22:16:59.842Z" },
{ url = "https://files.pythonhosted.org/packages/bc/33/fb0da992cf084ed1ee6ee05e96b6191e35c351eb707ca2df874feb24ce47/pillow-13.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e", size = 7300589, upload-time = "2026-10-16T22:17:01.686Z" },
{ url = "https://files.pythonhosted.org/packages/bb/af/c30dd9ef6a3dce84ecb1b2393cac629047a32e66279056c6cb46c3ffd65c/pillow-13.0.0-cp314-cp314t-win32.whl", hash = "sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15", size = 6517204, upload-time = "2026-10-16T22:17:03.722Z" },
{ url = "https://files.pythonhosted.org/packages/85/d5/ef3a8ae7609c8ab26570abf18194882534b480d245a9148b895a2262c004/pillow-13.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0", size = 7286695, upload-time = "2026-10-16T22:17:05.543Z" },
{ url = "https://files.pythonhosted.org/packages/84/3c/0861db0c69a03689ebe3e7599d35668a414deb5aaafd8255215e4e62e3d3/pillow-13.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692", size = 2698098, upload-time = "2026-10-16T22:17:07.674Z" },
{ url = "https://files.pythonhosted.org/packages/6e/eb/e86a41962a3a31ad495a3d6a912d9002b01bba4a6fa71c28703abdca7f69/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521", size = 4191213, upload-time = "2026-10-16T22:17:09.449Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/7e7445e706afec84ef06dc25b2c77f9cecff242c23730c62928ad1089d4a/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc", size = 4290650, upload-time = "2026-10-16T22:17:11.196Z" },
{ url = "https://files.pythonhosted.org/packages/46/85/d534089068ac40c0f61f650389a296c812eab745a8a733f81feec39ae955/pillow-13.0.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac", size = 3723078, upload-time = "2026-10-16T22:17:12.772Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2b/00b5c3b401df538b5dbe311d3899730fae5ba1e21cb8b80a128887ac6747/pillow-13.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d", size = 5572292, upload-time = "2026-10-16T22:17:14.539Z" },
{ url = "https://files.pythonhosted.org/packages/72/f5/a6b0610f946e9b54ae888ba7ad29d6e2f8d9b11f69e212d70416b7186c09/pillow-13.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782", size = 4852222, upload-time = "2026-10-16T22:17:16.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/d0/c0ff25bb0427ec70f32aac995453321fc3c4494a46c7292ff02d9d260b04/pillow-13.0.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071", size = 6527349, upload-time = "2026-10-16T22:17:18.534Z" },
{ url = "https://files.pythonhosted.org/packages/85/11/e46fa0bcc0fc117f1eacc206b3d51f04d583fab328b4b740672d0438d83f/pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf", size = 7152873, upload-time = "2026-10-16T22:17:20.415Z" },
{ url = "https://files.pythonhosted.org/packages/7c/53/2bdeca705f3a58679fa9201233193c86a30238340ca29b3a152c3fcc719b/pillow-13.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3", size = 6626871, upload-time = "2026-10-16T22:17:22.361Z" },
{ url = "https://files.pythonhosted.org/packages/14/de/3829030f209722ade3753bb456b805a0e7f885cf885b483407365c82e9ca/pillow-13.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932", size = 7236439, upload-time = "2026-10-16T22:17:24.352Z" },
{ url = "https://files.pythonhosted.org/packages/44/b1/67fc37388382bc036a74f04da5bb6fcbf90ec3ac1f45893e06176c373fd3/pillow-13.0.0-cp315-cp315-win32.whl", hash = "sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81", size = 6508688, upload-time = "2026-10-16T22:17:26.314Z" },
{ url = "https://files.pythonhosted.org/packages/44/05/fdb6a79aee10861165526b41b7b440e36ee1ff58613b2fbefe3acbf06d4f/pillow-13.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6", size = 7281107, upload-time = "2026-10-16T22:17:28.198Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9c/6c6f7dc2a566467baf121394e83de25ac287cffe34cc030726beb4be0224/pillow-13.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0", size = 2695513, upload-time = "2026-10-16T22:17:30.013Z" },
{ url = "https://files.pythonhosted.org/packages/23/16/e9120defdb8bff693b582d035312acc6f530cdc2bc5238a87f410efccd82/pillow-13.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f", size = 5578129, upload-time = "2026-10-16T22:17:31.642Z" },
{ url = "https://files.pythonhosted.org/packages/47/22/76faf2bb2448f48a84cf894c1e2bb1090d7d2b1aa521ece131cb2f05d631/pillow-13.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88", size = 4857428, upload-time = "2026-10-16T22:17:33.43Z" },
{ url = "https://files.pythonhosted.org/packages/b3/64/0861ea4e03c50b14af9ed9dd8b9f388ab42f445c623a9fcbab19aef7aa55/pillow-13.0.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb", size = 6612582, upload-time = "2026-10-16T22:17:35.415Z" },
{ url = "https://files.pythonhosted.org/packages/5c/20/cd1e9b3f60a9f3d5ebe46da36955c26004092a501f7f2c42f9fcc26611bc/pillow-13.0.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe", size = 7230176, upload-time = "2026-10-16T22:17:37.362Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ef/ffb0a2fc85690714fb7ac662956949645d57f15bf4426f520da361e7b6e3/pillow-13.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22", size = 6703640, upload-time = "2026-10-16T22:17:39.313Z" },
{ url = "https://files.pythonhosted.org/packages/e0/c2/6402ee3ccb3363aeed8fdb3f9fe56703ac5377a539f0b81b53999d7e121f/pillow-13.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630", size = 7309231, upload-time = "2026-10-16T22:17:41.021Z" },
{ url = "https://files.pythonhosted.org/packages/fc/96/d6b82e8b8c06ad143b66b12e0b31051e4bf489b4fabca1f390a14fe1ebde/pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2", size = 6517200, upload-time = "2026-10-16T22:17:42.886Z" },
{ url = "https://files.pythonhosted.org/packages/60/d4/0062e6005e9b270b165015aafd343364ac378ec5a685431ea42baad33357/pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43", size = 7287488, upload-time = "2026-10-16T22:17:45.56Z" },
{ url = "https://files.pythonhosted.org/packages/e4/81/b8eb349746be7bce47dd95337c6634e84910af91b59aaa9159c034c614fa/pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8", size = 2698073, upload-time = "2026-10-16T22:17:47.556Z" },
{ url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" },
{ url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" },
{ url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -2691,8 +2769,8 @@ name = "whenever"
version = "0.10.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "python_full_version >= '3.13' and sys_platform == 'win32'" },
{ name = "tzlocal", marker = "python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "tzlocal", marker = "sys_platform != 'darwin' and sys_platform != 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/40/94856fe2439c4b8162d1a23b4cf3a48bb00bed0ed7b8d10add40988fa555/whenever-0.10.3.tar.gz", hash = "sha256:af8958c870bd178742f0089c3552d5702241cab1f4b6bdfd3b03111db4bc2150", size = 435946, upload-time = "2026-07-17T07:35:25.734Z" }
wheels = [