diff --git a/api/kuaidi100/__init__.py b/api/kuaidi100/__init__.py new file mode 100644 index 0000000..95eb30f --- /dev/null +++ b/api/kuaidi100/__init__.py @@ -0,0 +1,8 @@ +"""快递100 上门取件API(线上支付) 客户端。 + +文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b +""" + +from api.kuaidi100.client import Kuaidi100ApiError, Kuaidi100Client + +__all__ = ["Kuaidi100Client", "Kuaidi100ApiError"] diff --git a/api/kuaidi100/client.py b/api/kuaidi100/client.py new file mode 100644 index 0000000..cf86ef1 --- /dev/null +++ b/api/kuaidi100/client.py @@ -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() diff --git a/api/sds/client.py b/api/sds/client.py index 7c8916a..8e80461 100644 --- a/api/sds/client.py +++ b/api/sds/client.py @@ -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: diff --git a/api/sds/models.py b/api/sds/models.py index a15ca50..d75929d 100644 --- a/api/sds/models.py +++ b/api/sds/models.py @@ -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 diff --git a/flows/sds/waybill_autogen.py b/flows/sds/waybill_autogen.py new file mode 100644 index 0000000..bc86407 --- /dev/null +++ b/flows/sds/waybill_autogen.py @@ -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) diff --git a/prefect.yaml b/prefect.yaml index b1f2353..06440c6 100644 --- a/prefect.yaml +++ b/prefect.yaml @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 783d2c3..0d50b48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "psutil>=7.2.2", "requests>=2.34.2", "aiohttp>=3.14.3", + "pillow>=13.0.0", ] [build-system] diff --git a/scripts/kuaidi100_callback_server.py b/scripts/kuaidi100_callback_server.py new file mode 100644 index 0000000..994b265 --- /dev/null +++ b/scripts/kuaidi100_callback_server.py @@ -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) + +落盘: /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() diff --git a/scripts/kuaidi100_pickup_test.py b/scripts/kuaidi100_pickup_test.py new file mode 100644 index 0000000..ba4d293 --- /dev/null +++ b/scripts/kuaidi100_pickup_test.py @@ -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() diff --git a/uv.lock b/uv.lock index c008b50..190e0d2 100644 --- a/uv.lock +++ b/uv.lock @@ -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 = [