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
yeuimu 4a7c44a354 feat: RIIN 商品拉取改用 goods/list 批量接口(200/批串行)替换逐单 order/goods
CI / test (pull_request) Successful in 1m11s
- api/riin/client.py: 新增 get_order_goods_batch(distribution/order/goods/list)
- flows/shared/clients.py: fetch_goods_concurrent → fetch_goods_for_orders,
  批量 200/批、串行、批间 2s,批级重试;移除逐单全局限速(340~500ms)机制
- flows/riin/_pipeline.py: 适配新批量拉取
- flows/shared/db.py: 存在性预查 unnest cast timestamp[] → timestamptz[]
  (入参为带时区偏移的 tz-aware datetime,timestamptz 才能稳定匹配)

背景:旧逐单接口高频被软限流(成功返回空 data),10-28 起日均缺口 13~33%;
批量接口调用量从日均 ~1 万次降到 ~50 次,已实测回补 7689 单/9717 条商品全量成功。
2026-10-31 10:01:50 +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
yeuimu 740bd803a9 fix: EVRI 面单 OCR 单号入库前去掉 '-'
CI / test (pull_request) Successful in 36s
2026-10-29 09:41:53 +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
yeuimu f87662ba4b fix: order/goods 限速收紧至峰值<3次/秒(340ms+0~160ms随机间隔)
CI / test (pull_request) Successful in 1m8s
2026-10-23 11:06:49 +00:00
yeuimu eb97ee1647 fix: RIIN order/goods 全局限速(并发20→5+150ms间隔,峰值≤6.7QPS);暂停 recent-sync/verify 调度(分销侧反馈调用量过大) 2026-10-23 10:57:46 +00:00
rom 0baac665da Merge pull request 'Develop' (#11) from develop into main
Reviewed-on: #11
2026-10-21 09:50:24 +00:00
rom 1a13075926 fix(ci): 锁定 Python 3.12,避免 uv 选 3.15 触发全量源码编译
CI / test (pull_request) Successful in 3m40s
2026-10-21 17:45:56 +08:00
rom 019cbadd1e feat: 新增 shop-dim-sync flow 并注册每 5 分钟调度
CI / test (pull_request) Canceled after 5m33s
2026-10-21 16:55:31 +08: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
yeuimu 2321e8ae9f Merge branch 'inkreach/ocr-pl': OCR 新增支持波兰(PL) 2026-10-15 03:14:40 +00:00
yeuimu e5175a0504 fix: 写库重试 3→5 次、退避拉长至 1/3/7/15/30s
CI / test (pull_request) Successful in 4m30s
sceptical-finch(riin-recent-sync 10-10 16:04)失败复盘:17track 物流回写
(宿主机本地进程,不走 advisory 锁)与 sync 写库在 panel 触发器链
(panel.orders/dims/dims_daily)上交叉持锁,冲突窗口持续约 3 分钟,
原 3 次重试(1/3/7s)全部耗尽。加深重试使其可跨过分钟级外部冲突窗口。
2026-10-10 09:08:01 +00:00
18 changed files with 1408 additions and 38 deletions
+1
View File
@@ -0,0 +1 @@
3.12
+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()
+8
View File
@@ -246,6 +246,14 @@ class RiinDisClient(TokenHttpClient):
data = self.post("distribution/order/goods", body={"orderId": order_id}) data = self.post("distribution/order/goods", body={"orderId": order_id})
return OrderGoodsResponse.model_validate(data) return OrderGoodsResponse.model_validate(data)
def get_order_goods_batch(self, ids: list[str]) -> OrderGoodsResponse:
"""批量获取订单商品(distribution/order/goods/list)。
上游要求:单批 ≤200 个 id 且串行调用;高频/并发会被软限流(成功但空 data)。
"""
data = self.post("distribution/order/goods/list", body={"ids": ids})
return OrderGoodsResponse.model_validate(data)
def get_order_count( def get_order_count(
self, params: OrderPageRequest | None = None self, params: OrderPageRequest | None = None
) -> OrderListCountResponse: ) -> OrderListCountResponse:
+97
View File
@@ -23,6 +23,7 @@ from api.sds.models import (
OrderQueryParams, OrderQueryParams,
OrderStatistics, OrderStatistics,
OrderStatus, OrderStatus,
ProductChildrenResponse,
ProductListRequest, ProductListRequest,
ProductListResponse, ProductListResponse,
) )
@@ -30,6 +31,7 @@ from api.sds.models import (
BASE_URL = "https://pod-api.sdspod.com" BASE_URL = "https://pod-api.sdspod.com"
COMPANY_NO = "印美达" COMPANY_NO = "印美达"
KEYWORDS_BATCH_SIZE = 200 KEYWORDS_BATCH_SIZE = 200
PRODUCT_CACHE_TTL_SECONDS = 24 * 3600 # 产品变体(克重等)是主数据,长缓存
FILTERS_FIELDS = ( FILTERS_FIELDS = (
"factoryList,merchantList,logisticsList,earlyWarningList," "factoryList,merchantList,logisticsList,earlyWarningList,"
"issuingBayAreaList,logisticsSourceList,extendStatusList" "issuingBayAreaList,logisticsSourceList,extendStatusList"
@@ -64,6 +66,7 @@ class SdsMngClient(TokenHttpClient):
self._username = username self._username = username
self._password = password self._password = password
self._cache: TTLCache[Any] = TTLCache(cache_ttl_seconds) self._cache: TTLCache[Any] = TTLCache(cache_ttl_seconds)
self._product_cache: TTLCache[Any] = TTLCache(PRODUCT_CACHE_TTL_SECONDS)
self._concurrency = concurrency self._concurrency = concurrency
# ----- 认证 ----- # ----- 认证 -----
@@ -129,6 +132,8 @@ class SdsMngClient(TokenHttpClient):
query["earlyWarningType"] = params.early_warning_type query["earlyWarningType"] = params.early_warning_type
if params.item_num_type is not None: if params.item_num_type is not None:
query["itemNumType"] = params.item_num_type query["itemNumType"] = params.item_num_type
if params.carriage_no_status is not None:
query["carriageNoStatus"] = params.carriage_no_status
return query return query
@staticmethod @staticmethod
@@ -297,6 +302,98 @@ class SdsMngClient(TokenHttpClient):
merged.extend(r.records) merged.extend(r.records)
return ProductListResponse(total_count=first.total_count, records=merged) 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: def get_logistics_areas(self) -> IssuingBayAreaResponse:
+21
View File
@@ -219,6 +219,26 @@ class ProductListResponse(_SdsModel):
records: list[Product] = Field(default=[], alias="list") 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)----- # ----- 物流域(混用 snake_case,显式 alias)-----
@@ -318,6 +338,7 @@ class OrderPageRequest(BaseModel):
extend_status: str | None = None extend_status: str | None = None
early_warning_type: str | None = None early_warning_type: str | None = None
item_num_type: str | None = None item_num_type: str | None = None
carriage_no_status: str | None = None # 运单号状态筛选,如 generate=运单待生成
limit: int | None = None limit: int | None = None
all: bool | None = None all: bool | None = None
+8 -1
View File
@@ -29,13 +29,20 @@ _OCR_FIELD_MAP = [
] ]
# 单号不需要 "-" 的承运商(Evri 单号两段式带 "-",下游查询用无 "-" 形式)
_DASH_STRIP_COURIERS = {"EVRI"}
def _result_to_fields(r) -> dict[str, str | None]: def _result_to_fields(r) -> dict[str, str | None]:
if not r.ok: if not r.ok:
err = (r.error or "").lower() err = (r.error or "").lower()
if "reject" in err or "not a recognized layout" in err: if "reject" in err or "not a recognized layout" in err:
return {"ocrLayout": "UNSUPPORTED"} return {"ocrLayout": "UNSUPPORTED"}
return {"ocrLayout": "ERROR"} return {"ocrLayout": "ERROR"}
return {col: (getattr(r, attr) or "") for attr, col in _OCR_FIELD_MAP} fields = {col: (getattr(r, attr) or "") for attr, col in _OCR_FIELD_MAP}
if (r.courier or "").strip().upper() in _DASH_STRIP_COURIERS:
fields["ocrTracking"] = fields["ocrTracking"].replace("-", "")
return fields
def _has_running_peer() -> bool: def _has_running_peer() -> bool:
+144
View File
@@ -0,0 +1,144 @@
# -*- coding: utf-8 -*-
"""shop_dim 同步 flow:teable(源) -> 本地 smt 库(目标)。
最小包装自 sync_shop_dim.py,对应 shop-dim-sync,每 5 分钟调度一次。
源: PostgreSQL @ table.inkreach.cc:10432 db=teable schema=bsehWCxpH6HoHUdkjUd
物理表 tblUF7OrBxeRKUyWBc2 (teable 里名为 "shop_dim" 的表)
目标: PostgreSQL @ 192.168.124.92:5432 db=smt public.shop_dim
本地直接跑: python flows/platform_order/sync_shop_dim.py
"""
import time
from typing import Any
import psycopg2
from prefect import flow
SRC = dict(host="table.inkreach.cc", port=10432, dbname="teable",
user="read_only_role_bsehWCxpH6HoHUdkjUd",
password="ygBuh8Au5J-PjuNQASos0", connect_timeout=25)
SRC_TABLE = '"bsehWCxpH6HoHUdkjUd"."tblUF7OrBxeRKUyWBc2"'
DST = dict(host="192.168.124.92", port=5432, dbname="smt",
user="postgres", password="Inkreach123456.", connect_timeout=15)
COL_MAP = [
("Guo_Jia", "country"),
("Dian_Pu_Ming_Cheng", "shop_name"),
("DBDian_Pu_Ming", "db_store_name"),
("Ping_Tai", "platform"),
("Lei_Xing", "shop_type"),
("Deng_Ji", "tier"),
("Fu_Ze_Ren", "owner"),
("Pin_Lei", "category"),
("Xia_Dian_Shi_Jian", "opened_at"),
("Mu_Biao_Jian_Shu", "sep_target_qty"),
]
def log_msg(msg):
print(msg)
def connect(cfg, tries=5):
last = None
for i in range(tries):
try:
return psycopg2.connect(**cfg)
except Exception as e:
last = e
log_msg(f" 连接重试 {i+1}/{tries}: {e}")
time.sleep(3)
raise ConnectionError(f"连接失败: {last}")
def norm(v, col):
if v is None:
return None
if col == "opened_at":
return v.date() if hasattr(v, "date") else v
if col == "sep_target_qty":
try:
return int(round(float(v)))
except Exception:
return None
s = str(v).strip()
return s if s != "" else None
@flow(name="shop-dim-sync", log_prints=True)
def shop_dim_sync(dry_run: bool = False, prune: bool = True) -> dict[str, Any]:
log_msg(f"[1/4] 读取源表 {SRC_TABLE} ...")
sc = connect(SRC)
scur = sc.cursor()
sel = ", ".join('"%s"' % c for c, _ in COL_MAP)
scur.execute("SELECT %s FROM %s" % (sel, SRC_TABLE))
raw = scur.fetchall()
scur.close(); sc.close()
src_rows = [
dict(zip([d for _, d in COL_MAP], [norm(v, c) for v, (_, c) in zip(row, COL_MAP)]))
for row in raw
]
log_msg(f" 源记录: {len(src_rows)} 行")
log_msg("[2/4] 连接目标 smt.shop_dim ...")
dc = connect(DST)
dcur = dc.cursor()
dcur.execute("SELECT country, db_store_name FROM shop_dim WHERE db_store_name IS NOT NULL")
existing = {(r[0], r[1]) for r in dcur.fetchall()}
log_msg(f" 目标现有: {len(existing)} 个 (country, db_store_name)")
src_keys = {(r["country"], r["db_store_name"]) for r in src_rows}
to_update = src_keys & existing
to_insert = src_keys - existing
to_delete = existing - src_keys
log_msg(f"[3/4] 计划: 更新 {len(to_update)} | 新增 {len(to_insert)} | 待删 {len(to_delete)}")
if dry_run:
log_msg("[dry-run] 不执行写入。")
if to_delete:
log_msg(" 待删示例: " + str(list(to_delete)[:10]))
dcur.close(); dc.close()
return {"sourceRows": len(src_rows), "dryRun": True}
cols = [d for _, d in COL_MAP]
upd = 0
ins = 0
for r in src_rows:
vals = [r[c] for c in cols]
if (r["country"], r["db_store_name"]) in existing:
set_clause = ", ".join(f"{c}=%s" for c in cols)
dcur.execute(
f"UPDATE shop_dim SET {set_clause} WHERE country=%s AND db_store_name=%s",
vals + [r["country"], r["db_store_name"]])
upd += dcur.rowcount
else:
ph = ", ".join(["%s"] * len(cols))
dcur.execute(f"INSERT INTO shop_dim ({', '.join(cols)}) VALUES ({ph})", vals)
ins += dcur.rowcount
dele = 0
if prune and to_delete:
for country, dbn in to_delete:
dcur.execute("DELETE FROM shop_dim WHERE country=%s AND db_store_name=%s", (country, dbn))
dele += dcur.rowcount
dc.commit()
log_msg(f"[4/4] 完成: 更新 {upd} | 新增 {ins} | 删除 {dele}")
dcur.execute("SELECT COUNT(*) FROM shop_dim WHERE db_store_name IS NOT NULL")
total = dcur.fetchone()[0]
log_msg(f" 目标现有总数: {total}")
dcur.close(); dc.close()
return {
"sourceRows": len(src_rows),
"updated": upd,
"inserted": ins,
"deleted": dele,
"total": total,
}
if __name__ == "__main__":
shop_dim_sync.serve(name="shop-dim-sync", cron="*/5 * * * *")
+3 -3
View File
@@ -1,10 +1,10 @@
"""RIIN 同步公共管道:映射订单 → 并发拉商品 → upsert 到 PG。""" """RIIN 同步公共管道:映射订单 → 批量拉商品 → upsert 到 PG。"""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from flows.shared import fetch_goods_concurrent from flows.shared import fetch_goods_for_orders
from flows.shared.db import SyncDB from flows.shared.db import SyncDB
from flows.shared.mappers import map_riin_goods, map_riin_order from flows.shared.mappers import map_riin_goods, map_riin_order
from api import RiinDisClient from api import RiinDisClient
@@ -31,7 +31,7 @@ def sync_riin_records(raw_orders: list[Any], client: RiinDisClient, db: SyncDB)
goods_inputs: list[Any] = [] goods_inputs: list[Any] = []
if order_meta: if order_meta:
goods_map = fetch_goods_concurrent(client, list(order_meta.keys())) goods_map = fetch_goods_for_orders(client, list(order_meta.keys()))
for oid, (order_no, created_at) in order_meta.items(): for oid, (order_no, created_at) in order_meta.items():
items = goods_map.get(oid, []) items = goods_map.get(oid, [])
if not items: if not items:
+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)
+2 -2
View File
@@ -5,7 +5,7 @@ from flows.shared.artifacts import build_stats_rows, emit_sync_summary, emit_ver
from flows.shared.clients import ( from flows.shared.clients import (
create_riin_client, create_riin_client,
create_sds_client, create_sds_client,
fetch_goods_concurrent, fetch_goods_for_orders,
fetch_riin_all_pages, fetch_riin_all_pages,
fetch_sds_all_pages, fetch_sds_all_pages,
floor_to_hour, floor_to_hour,
@@ -23,7 +23,7 @@ __all__ = [
"clients", "db", "mappers", "SyncDB", "clients", "db", "mappers", "SyncDB",
"OrderUpsertInput", "GoodsUpsertInput", "OrderUpsertInput", "GoodsUpsertInput",
"create_riin_client", "create_sds_client", "create_riin_client", "create_sds_client",
"fetch_riin_all_pages", "fetch_sds_all_pages", "fetch_goods_concurrent", "fetch_riin_all_pages", "fetch_sds_all_pages", "fetch_goods_for_orders",
"get_channel_type_map", "refresh_channel_type_map", "resolve_courier_source", "get_channel_type_map", "refresh_channel_type_map", "resolve_courier_source",
"format_date", "start_of_day", "floor_to_hour", "format_date", "start_of_day", "floor_to_hour",
"build_stats_rows", "emit_sync_summary", "emit_verify_report", "build_stats_rows", "emit_sync_summary", "emit_verify_report",
+42 -24
View File
@@ -21,7 +21,6 @@ from flows.shared.mappers import map_sds_channel_type
T = TypeVar("T") T = TypeVar("T")
GOODS_CONCURRENCY = 20
PAGE_DELAY_MS = 500 PAGE_DELAY_MS = 500
MAX_RETRIES = 3 MAX_RETRIES = 3
RETRY_DELAY_MS = 3000 RETRY_DELAY_MS = 3000
@@ -144,39 +143,58 @@ def fetch_sds_all_pages(client: SdsMngClient, params: dict[str, Any]) -> list[An
# ---------- RIIN 商品 ---------- # ---------- RIIN 商品 ----------
def fetch_goods_for_order(client: RiinDisClient, order_id: str) -> list[Any]: # 批量接口 distribution/order/goods/list:单批 ≤200 个 id,且要求串行调用(分销侧约定)。
try: # 相比旧逐单接口(软限流:高频返回成功但空 data),调用量从日均 ~1 万次降到 ~50 次。
resp = client.get_order_goods(order_id) GOODS_BATCH_SIZE = 200
return list(resp.data) GOODS_BATCH_INTERVAL_MS = 2000 # 相邻两批的串行间隔
except Exception as err: # noqa: BLE001
print(f"[RIIN sync] 订单 {order_id} 商品拉取失败: {err}")
return []
def fetch_goods_concurrent(client: RiinDisClient, order_ids: list[str]) -> dict[str, list[Any]]: def fetch_goods_for_orders(client: RiinDisClient, order_ids: list[str]) -> dict[str, list[Any]]:
"""以 GOODS_CONCURRENCY 并发拉取多订单商品,返回 {order_id: [goods...]}。""" """按 goods/list 批量接口串行拉取多订单商品,返回 {order_id: [goods...]}。
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
results: dict[str, list[Any]] = {} 单批失败(网络/业务错误)整批重试最多 MAX_RETRIES 次,仍失败则该批订单记空列表,
由调用方按"无商品订单"计数(与旧逐单路径的容错语义一致)。
"""
results: dict[str, list[Any]] = {oid: [] for oid in order_ids}
if not order_ids: if not order_ids:
return results return results
concurrency = min(GOODS_CONCURRENCY, len(order_ids)) total_batches = -(-len(order_ids) // GOODS_BATCH_SIZE)
print(f"[RIIN sync] 商品拉取开始: {len(order_ids)} 个订单 (并发={concurrency})") print(f"[RIIN sync] 商品批量拉取开始: {len(order_ids)} 个订单 / {total_batches} 批"
f" (goods/list 串行, 批间 {GOODS_BATCH_INTERVAL_MS}ms)")
def make(oid: str) -> Callable[[], None]: for bi in range(total_batches):
def run() -> None: chunk = order_ids[bi * GOODS_BATCH_SIZE : (bi + 1) * GOODS_BATCH_SIZE]
results[oid] = fetch_goods_for_order(client, oid) items = _fetch_goods_batch_with_retry(client, chunk, bi + 1)
return run for g in items:
if g.order_id in results:
with ThreadPoolExecutor(max_workers=concurrency) as pool: results[g.order_id].append(g)
list(pool.map(lambda fn: fn(), [make(oid) for oid in order_ids])) if bi < total_batches - 1:
time.sleep(GOODS_BATCH_INTERVAL_MS / 1000)
total_goods = sum(len(v) for v in results.values()) total_goods = sum(len(v) for v in results.values())
empty_orders = sum(1 for v in results.values() if not v) empty_orders = sum(1 for v in results.values() if not v)
print(f"[RIIN sync] 商品拉取完成: 共 {total_goods} 条商品, {empty_orders} 个订单无商品") print(f"[RIIN sync] 商品批量拉取完成: 共 {total_goods} 条商品, {empty_orders} 个订单无商品")
return results return results
def _fetch_goods_batch_with_retry(client: RiinDisClient, chunk: list[str], batch_no: int) -> list[Any]:
last_err: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
resp = fetch_with_retry(
lambda: client.get_order_goods_batch(chunk), f"RIIN goods 批次 {batch_no}"
)
if resp.successful is False:
raise RuntimeError(f"goods/list 业务失败: {resp.message}")
return list(resp.data)
except Exception as err: # noqa: BLE001
last_err = err
print(f"[RIIN sync] 商品批次 {batch_no} 第 {attempt}/{MAX_RETRIES} 次失败: {str(err)[:120]}")
if attempt < MAX_RETRIES:
time.sleep(RETRY_DELAY_MS * attempt / 1000)
assert last_err is not None
print(f"[RIIN sync] 商品批次 {batch_no} 放弃, {len(chunk)} 个订单本轮无商品")
return []
# ---------- SDS 物流渠道字典 ---------- # ---------- SDS 物流渠道字典 ----------
_channel_cache: dict[str, Any] = {} _channel_cache: dict[str, Any] = {}
+7 -4
View File
@@ -25,8 +25,9 @@ LOOKUP_BATCH = 10000
# 单锁无环,写事务之间从结构上不可能死锁。1768851058 = "inkr" 的 ASCII 整数表示。 # 单锁无环,写事务之间从结构上不可能死锁。1768851058 = "inkr" 的 ASCII 整数表示。
DB_WRITE_LOCK_KEY = 1768851058 DB_WRITE_LOCK_KEY = 1768851058
LOCK_TIMEOUT = "15s" # 等锁超时 → LockNotAvailable → 交给重试兜底 LOCK_TIMEOUT = "15s" # 等锁超时 → LockNotAvailable → 交给重试兜底
WRITE_RETRIES = 3 # 死锁/等锁超时的整事务重试次数 WRITE_RETRIES = 5 # 死锁/等锁超时的整事务重试次数(外部写者如 17track
WRITE_RETRY_BACKOFF = [1, 3, 7] # 重试退避(秒) # 物流回写不走 advisory 锁,冲突窗口可达分钟级,重试需够深)
WRITE_RETRY_BACKOFF = [1, 3, 7, 15, 30] # 重试退避(秒;末位预留,最后一次失败直接抛出)
WRITE_RETRYABLE_ERRORS = (pg_errors.DeadlockDetected, pg_errors.LockNotAvailable) WRITE_RETRYABLE_ERRORS = (pg_errors.DeadlockDetected, pg_errors.LockNotAvailable)
@@ -459,9 +460,11 @@ class SyncDB:
stats = {"ordersCreated": 0, "ordersUpdated": 0, "goodsCreated": 0, "goodsUpdated": 0, "goodsRemoved": 0} stats = {"ordersCreated": 0, "ordersUpdated": 0, "goodsCreated": 0, "goodsUpdated": 0, "goodsRemoved": 0}
with conn.cursor() as cur: with conn.cursor() as cur:
# 统计已存在 → created/updated # 统计已存在 → created/updated
# 注:必须 cast 成 timestamptz——入参是带时区偏移的 tz-aware datetime,
# cast 成 timestamp 会丢弃偏移,在会话时区非 +08 时匹配不到既有行,导致 created/updated 统计全错。
pairs = [(o.orderNo, o.createdAt) for o in orders] pairs = [(o.orderNo, o.createdAt) for o in orders]
cur.execute( cur.execute(
"SELECT t.order_no, t.created_at FROM unnest(%s::text[], %s::timestamp[]) AS t(order_no,created_at) " "SELECT t.order_no, t.created_at FROM unnest(%s::text[], %s::timestamptz[]) AS t(order_no,created_at) "
"JOIN \"Orders\" o ON o.\"orderNo\"=t.order_no AND o.\"createdAt\"=t.created_at", "JOIN \"Orders\" o ON o.\"orderNo\"=t.order_no AND o.\"createdAt\"=t.created_at",
_unnest_pairs(pairs), _unnest_pairs(pairs),
) )
@@ -622,6 +625,6 @@ class SyncDB:
def _unnest_pairs(pairs: list[tuple]) -> tuple[list, list]: def _unnest_pairs(pairs: list[tuple]) -> tuple[list, list]:
"""构造 psycopg2 unnest 双数组参数:返回 (col_a_list, col_b_list)。 """构造 psycopg2 unnest 双数组参数:返回 (col_a_list, col_b_list)。
配合 SQL:unnest(%s::text[], %s::timestamp[]) 或 unnest(%s::text[], %s::text[])。 配合 SQL:unnest(%s::text[], %s::timestamptz[]) 或 unnest(%s::text[], %s::text[])。
""" """
return [p[0] for p in pairs], [p[1] for p in pairs] return [p[0] for p in pairs], [p[1] for p in pairs]
+17
View File
@@ -21,6 +21,7 @@ deployments:
schedules: schedules:
- cron: 30 * * * * - cron: 30 * * * *
timezone: Asia/Shanghai timezone: Asia/Shanghai
active: false # 暂停:每小时重拉近7天全部订单商品,对 RIIN distribution/order/goods 调用量过大(增量/刷新未完结/日报核验已覆盖)
work_pool: work_pool:
name: inkreach-pool name: inkreach-pool
- name: riin-refresh-pending - name: riin-refresh-pending
@@ -39,6 +40,7 @@ deployments:
schedules: schedules:
- cron: 0 6 * * * - cron: 0 6 * * *
timezone: Asia/Shanghai timezone: Asia/Shanghai
active: false # 暂停:与 daily-report 18:30 的核验子流程重复,保留日报一处即可
work_pool: work_pool:
name: inkreach-pool name: inkreach-pool
- name: sds-order-sync - name: sds-order-sync
@@ -80,6 +82,14 @@ deployments:
timezone: Asia/Shanghai timezone: Asia/Shanghai
work_pool: work_pool:
name: inkreach-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 - name: platform-order-riin
entrypoint: flows/platform_order/riin.py:riin_platform_sync entrypoint: flows/platform_order/riin.py:riin_platform_sync
schedules: schedules:
@@ -108,6 +118,13 @@ deployments:
timezone: Asia/Shanghai timezone: Asia/Shanghai
work_pool: work_pool:
name: inkreach-pool name: inkreach-pool
- name: shop-dim-sync
entrypoint: flows/platform_order/sync_shop_dim.py:shop_dim_sync
schedules:
- cron: '*/5 * * * *'
timezone: Asia/Shanghai
work_pool:
name: inkreach-pool
- name: label-ocr - name: label-ocr
entrypoint: flows/label_ocr.py:label_ocr entrypoint: flows/label_ocr.py:label_ocr
schedules: schedules:
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"psutil>=7.2.2", "psutil>=7.2.2",
"requests>=2.34.2", "requests>=2.34.2",
"aiohttp>=3.14.3", "aiohttp>=3.14.3",
"pillow>=13.0.0",
] ]
[build-system] [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 = "aiohttp" },
{ name = "httpx" }, { name = "httpx" },
{ name = "pandas" }, { name = "pandas" },
{ name = "pillow" },
{ name = "prefect" }, { name = "prefect" },
{ name = "prefect-sqlalchemy" }, { name = "prefect-sqlalchemy" },
{ name = "psutil" }, { name = "psutil" },
@@ -965,6 +966,7 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.14.3" }, { name = "aiohttp", specifier = ">=3.14.3" },
{ name = "httpx" }, { name = "httpx" },
{ name = "pandas" }, { name = "pandas" },
{ name = "pillow", specifier = ">=13.0.0" },
{ name = "prefect", specifier = ">=3.4" }, { name = "prefect", specifier = ">=3.4" },
{ name = "prefect-sqlalchemy" }, { name = "prefect-sqlalchemy" },
{ name = "psutil", specifier = ">=7.2.2" }, { name = "psutil", specifier = ">=7.2.2" },
@@ -1483,8 +1485,8 @@ name = "pendulum"
version = "3.2.0" version = "3.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "python-dateutil", marker = "python_full_version < '3.13'" }, { name = "python-dateutil" },
{ name = "tzdata", marker = "python_full_version < '3.13'" }, { 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" } 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 = [ 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" }, { 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]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -2691,8 +2769,8 @@ name = "whenever"
version = "0.10.3" version = "0.10.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "tzdata", marker = "python_full_version >= '3.13' and sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "tzlocal", marker = "python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux'" }, { 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" } 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 = [ wheels = [