初始化项目 v2.0:中国邮政揽收系统自动填单工具

基于 HTTP API + Node.js token bridge 方案,不依赖浏览器自动化。
This commit is contained in:
2026-05-15 14:34:35 +08:00
commit e99dfaef4d
20 changed files with 4432 additions and 0 deletions
+604
View File
@@ -0,0 +1,604 @@
"""揽收系统 HTTP API 封装 — 直接调用后端接口,不依赖浏览器。
依赖 Node.js token bridge 计算 $_ts 安全框架的 c93l5Fya 动态 token。
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from typing import Any
from urllib.parse import urlparse, urljoin
from log import debug
# ─── Token Bridge 管理 ────────────────────────────────────────────────
def _get_bridge_dir() -> str:
"""获取 token_bridge 目录路径(支持 PyInstaller 打包)。"""
if getattr(sys, "frozen", False):
base = sys._MEIPASS
else:
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, "token_bridge")
class TokenBridge:
"""管理 Node.js token 计算子进程(HTTP 通信)。"""
def __init__(self):
self._proc: subprocess.Popen | None = None
self._port: int = 0
self._base_url: str = ""
def start(self):
bridge_dir = _get_bridge_dir()
kwargs = dict(
cwd=bridge_dir,
stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace",
)
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
self._proc = subprocess.Popen(["node", "server.js", "0"], **kwargs)
# 进程退出时强制杀子进程,防止 _MEIPASS 目录无法删除
import atexit as _atexit
_atexit.register(self.stop)
# 读取就绪信号(包含端口号)
line = self._proc.stderr.readline()
try:
msg = json.loads(line.strip())
if not msg.get("ready"):
raise RuntimeError(f"Bridge startup failed: {line}")
self._port = msg["port"]
self._base_url = f"http://127.0.0.1:{self._port}"
except json.JSONDecodeError:
raise RuntimeError(f"Bridge unexpected stderr: {line}")
# 后台线程持续读取 stderr 并输出到 log
import threading as _threading
def _read_stderr():
from log import trace
for line in self._proc.stderr:
trace(f"bridge stderr: {line.strip()}")
_threading.Thread(target=_read_stderr, daemon=True).start()
def stop(self):
if self._proc:
try:
self._proc.kill()
self._proc.wait(timeout=3)
except Exception:
pass
self._proc = None
self._port = 0
def _request(self, path: str, body: dict = None) -> dict:
"""向 bridge HTTP 服务器发送请求。"""
from log import debug, trace
import requests as _requests
url = f"{self._base_url}{path}"
trace(f"bridge -> {path} {str(body)[:100] if body else ''}")
try:
if body:
resp = _requests.post(url, json=body, timeout=30)
else:
resp = _requests.get(url, timeout=30)
resp.raise_for_status()
result = resp.json()
trace(f"bridge <- {str(result)[:100]}")
if "error" in result:
raise RuntimeError(f"Bridge error: {result['error']}")
return result
except _requests.RequestException as e:
# 读取 stderr
err = ""
try:
while True:
chunk = self._proc.stderr.readline()
if not chunk:
break
err += chunk
except Exception:
pass
if err:
raise RuntimeError(f"Bridge request failed: {e}, stderr={err[:300]}")
raise
def initialize(self, html: str, scripts: list[dict], base_url: str):
return self._request("/init", {
"html": html,
"scripts": scripts,
"baseUrl": base_url,
})
def compute_token(self, method: str, path: str) -> str:
result = self._request("/token", {
"method": method,
"path": path,
})
return result["token"]
def ping(self) -> bool:
try:
self._request("/ping")
return True
except Exception:
return False
# ─── HTTP 会话 & 页面资源管理 ─────────────────────────────────────────
class PickupSession:
"""管理 HTTP 会话(cookies)和页面资源加载。"""
def __init__(self, base_url: str):
self.base_url = base_url.rstrip("/")
parsed = urlparse(self.base_url)
self._origin = f"{parsed.scheme}://{parsed.netloc}"
# 使用 requests 保持 cookie 会话(同步,足够简单可靠)
import requests as _requests
self._http: _requests.Session = _requests.Session()
self._http.verify = False # 内部自签证书
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self._http.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/109.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
})
self._scripts: list[dict] = []
self._html: str = ""
self._pickup_path: str = ""
self._ts_cd: str = ""
self._ts_nsd: int = 0
@property
def cookies_str(self) -> str:
"""将 requests cookies 转为 Cookie 请求头字符串。"""
pairs = []
for cookie in self._http.cookies:
pairs.append(f"{cookie.name}={cookie.value}")
return "; ".join(pairs)
def load_session(self, session_path: str) -> bool:
"""从文件加载之前保存的 cookies。返回是否加载成功。"""
import requests as _requests
try:
with open(session_path, "r") as f:
data = json.load(f)
seen = set()
for cookie_data in data.get("cookies", []):
key = (cookie_data["name"], cookie_data.get("domain", ""), cookie_data.get("path", "/"))
if key in seen:
continue
seen.add(key)
self._http.cookies.set(
cookie_data["name"],
cookie_data["value"],
domain=cookie_data.get("domain", ""),
path=cookie_data.get("path", "/"),
)
return len(seen) > 0
except (FileNotFoundError, json.JSONDecodeError, KeyError):
return False
def save_session(self, session_path: str):
"""保存 cookies 到文件。"""
data = {
"cookies": [
{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path}
for c in self._http.cookies
]
}
with open(session_path, "w") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _abs_url(self, path_or_url: str) -> str:
if path_or_url.startswith("http"):
return path_or_url
return urljoin(self._origin, path_or_url)
def fetch_page_resources(self) -> dict:
"""加载 pickup 页面及所需 JS 文件。返回 {html, scripts, base_url, nsd}。"""
import requests as _requests
pickup_path = urlparse(self.base_url).path
self._pickup_path = pickup_path
# 加载 pickup 页面(需要 Referer 满足安全框架要求)
portal_url = f"{self._origin}/portal/a"
debug(f"fetch_page: loading {self.base_url}, cookies={list(self._http.cookies.keys())}")
resp = self._http.get(
self.base_url,
headers={
"Referer": portal_url,
"Sec-Fetch-Dest": "iframe",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Upgrade-Insecure-Requests": "1",
},
timeout=30,
)
debug(f"fetch_page: status={resp.status_code}")
if resp.status_code != 200:
debug(f"fetch_page: resp_headers={dict(resp.headers)}")
debug(f"fetch_page: resp_body={resp.text[:500]}")
resp.raise_for_status()
self._html = resp.text
# 从页面 HTML 中提取 $_ts 安全参数
self._ts_cd = ""
self._ts_nsd = 0
m = re.search(r'\$_ts\.cd="([^"]*)"', self._html)
if m:
self._ts_cd = m.group(1)
m = re.search(r'\$_ts\.nsd=(\d+)', self._html)
if m:
self._ts_nsd = int(m.group(1))
if not self._ts_cd or not self._ts_nsd:
raise RuntimeError("Failed to extract $_ts.cd/nsd from page")
# 2. 提取外部 JS 引用,只保留 security JS(cfd114a.js)
# 页面业务脚本(jQuery、main.js 等)会导致 jsdom 卡死,不需要
script_urls = re.findall(
r'<script[^>]+src="([^"]+)"[^>]*>', self._html, re.IGNORECASE
)
self._scripts = []
for url in script_urls:
if "cfd114a.js" not in url:
continue
abs_url = self._abs_url(url)
try:
script_resp = self._http.get(abs_url, timeout=15)
if script_resp.status_code == 200:
self._scripts.append({"url": url, "content": script_resp.text})
debug(f"fetch_page: loaded security JS {url}")
except _requests.RequestException:
debug(f"Failed to fetch script: {abs_url}")
if not self._scripts:
raise RuntimeError("Security JS (cfd114a.js) not found on page")
return {
"html": self._html,
"scripts": self._scripts,
"base_url": self.base_url,
"ts_cd": self._ts_cd,
"ts_nsd": self._ts_nsd,
}
def request(self, method: str, path: str, token: str,
data: dict[str, str] = None) -> dict[str, Any]:
"""向 pickup-web 后端发送 API 请求。
Args:
method: HTTP 方法 (GET/POST)
path: API 路径 (如 /pickup-web/a/common/customerautocomplete)
token: c93l5Fya 安全 token
data: POST 表单数据
Returns:
JSON 响应(如果是 JSON),否则返回 {"_text": response_text}
"""
full_url = self._abs_url(path)
if "?" in full_url:
full_url += f"&c93l5Fya={token}"
else:
full_url += f"?c93l5Fya={token}"
headers = {
"X-Requested-With": "XMLHttpRequest",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Referer": self.base_url,
}
if data:
headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
debug(f"API {method} {path}")
resp = self._http.request(
method, full_url,
headers=headers,
data=data or None,
timeout=30,
)
debug(f"API {method} {path} → {resp.status_code}")
if resp.status_code >= 400:
debug(f"API error body: {resp.text[:500]}")
resp.raise_for_status()
text = resp.text
if text and ("<html" in text[:300].lower() or "<!doctype" in text[:300].lower()):
raise RuntimeError("Session 已过期,返回了登录页面,请重新登录并获取 cookies")
content_type = resp.headers.get("Content-Type", "")
if "json" in content_type or text.strip().startswith(("[", "{")):
try:
return resp.json()
except ValueError:
pass
return {"_text": text}
# ─── 业务 API ─────────────────────────────────────────────────────────
class PickupAPI:
"""揽收系统业务 API 封装。
每个方法对应页面上的一个 AJAX 操作。
所有需要 token 的方法都通过 token_bridge 计算 c93l5Fya 参数。
"""
def __init__(self, session: PickupSession, bridge: TokenBridge):
self._sess = session
self._bridge = bridge
def _token(self, method: str, path: str) -> str:
return self._bridge.compute_token(method, path)
def _post(self, path: str, data: dict[str, str] = None) -> dict:
token = self._token("POST", path)
return self._sess.request("POST", path, token, data)
def _get(self, path: str) -> dict:
token = self._token("GET", path)
return self._sess.request("GET", path, token)
# ── 产品 ──────────────────────────────────────────────────
def query_product(self, keyword: str) -> list[dict]:
"""搜索产品名称,返回候选列表(data=id|bizProductNo, value=显示名)。"""
result = self._post(
"/pickup-web/a/postwaybilledit/queryproductmanager",
data={"no": "on", "query": keyword},
)
from log import trace
trace(f"query_product raw: {str(result)[:300]}")
suggestions = result.get("suggestions", []) if isinstance(result, dict) else []
# 解析 data 字段: "productId|bizProductNo"
parsed = []
for s in suggestions:
data_parts = s.get("data", "").split("|")
parsed.append({
"productId": data_parts[0] if len(data_parts) > 0 else "",
"bizProductNo": data_parts[1] if len(data_parts) > 1 else "",
"productName": s.get("value", ""),
})
return parsed
def select_product(self, biz_product_id: str, biz_product_no: str) -> dict:
"""选择产品后获取产品详情(模板、材料等)。"""
product_info = {"bizProductId": biz_product_id, "bizProductNo": biz_product_no}
# 获取模板(返回数组,取第一个)
template = self._post(
"/pickup-web/a/common/templatecodemodel",
data={"bizProductNo": biz_product_no},
)
tmpl = template[0] if isinstance(template, list) and template else template
if isinstance(tmpl, dict):
product_info.update(tmpl)
# 查询产品详情
detail = self._post(
"/pickup-web/a/common/queryproductbymanager",
data={
"bizProductId": biz_product_id,
"bizProductNo": biz_product_no,
},
)
if isinstance(detail, dict):
product_info.update(detail)
elif isinstance(detail, list) and detail:
product_info.update(detail[0])
# 查询物料信息(返回数组或 dict)
material = self._post(
"/pickup-web/a/common/findppoductmaterialid",
data={"productCode": biz_product_no},
)
mat = material[0] if isinstance(material, list) and material else material
if isinstance(mat, dict):
product_info["material"] = mat
return product_info
# ── 客户 ──────────────────────────────────────────────────
def query_customer(self, keyword: str) -> list[dict]:
"""搜索客户名称,返回候选列表(data=customerId, valueName=短名, value=全名)。"""
result = self._post(
"/pickup-web/a/common/customerautocomplete",
data={"query": keyword},
)
from log import trace
trace(f"query_customer raw: {str(result)[:300]}")
suggestions = result.get("suggestions", []) if isinstance(result, dict) else []
parsed = []
for s in suggestions:
parsed.append({
"customerId": s.get("data", ""),
"customerName": s.get("valueName", s.get("value", "")),
"customerFullName": s.get("value", ""),
})
return parsed
def select_customer(self, customer_id: str) -> dict:
"""选择客户后获取客户详情。"""
result = self._post(
"/pickup-web/a/common/customerins",
data={"customerId": customer_id},
)
if isinstance(result, dict):
return result
return {}
def find_sender_history(self, sender_no: str) -> dict:
"""根据寄件人编号查询实名历史。"""
result = self._post(
"/pickup-web/a/postwaybillargs/findrealnamehistorybysender",
data={"senderNo": sender_no},
)
if isinstance(result, dict):
return result
return {}
# ── 运单 ──────────────────────────────────────────────────
def query_waybill(self, waybill_no: str, ecommerce_no: str,
biz_product_no: str, payment_mode: str,
biz_product_id: str = "",
post_blend: str = "0",
lock_customer: str = "0") -> dict:
"""查询运单信息。"""
return self._post(
"/pickup-web/a/postwaybill/querypostwaybillmanager",
data={
"waybillNo": waybill_no,
"ecommerceNo": ecommerce_no,
"bizProductNo": biz_product_no,
"postBlend": post_blend,
"lockCustomer": lock_customer,
"paymentMode": payment_mode,
"bizProductId": biz_product_id,
},
)
# ── 地址 ──────────────────────────────────────────────────
def address_match_raw(self, receiver_addr: str) -> dict:
"""根据地址文本查询区划代码(addressMatch API)。"""
return self._post(
"/pickup-web/a/postwaybill/addressMatch",
data={"receiverAddr": receiver_addr},
)
def query_district(self, dist_code: str) -> dict:
"""根据区划代码查询地址基本信息。"""
return self._post(
"/pickup-web/a/postwaybilledit/addressbasicno",
data={"dto": dist_code},
)
def match_address(self, receiver_arrive_org_no: str,
base_product_no: str,
waybill_no: str,
receiver_addr: str,
sender_no: str = "",
transfer_type: str = "3",
contents_attribute: str = "3",
single_seal_flag: str = "0",
inter_flag: str = "0",
reserved29: str = "0",
safety_type: str = "0",
io_type: str = "10") -> dict:
"""匹配收件地址,获取寄达地网格等信息。"""
return self._post(
"/pickup-web/a/common/findGridPrammer",
data={
"receiverArriveOrgNo": receiver_arrive_org_no,
"baseProductNo": base_product_no,
"transferType": transfer_type,
"contentsAttribute": contents_attribute,
"singleSealFlag": single_seal_flag,
"interFlag": inter_flag,
"reserved29": reserved29,
"waybillNo": waybill_no,
"receiverAddr": receiver_addr,
"senderNo": sender_no,
"safetyType": safety_type,
"ioType": io_type,
},
)
# ── 校验 ──────────────────────────────────────────────────
def check_product_material(self, cargo_names: str, waybill_no: str,
product_code: str, dist_code: str) -> dict:
"""校验产品物料。"""
return self._post(
"/pickup-web/a/common/checkppoductmaterialidnew",
data={
"cargoNames": cargo_names,
"cargoOptions": "",
"waybillNo": waybill_no,
"productCode": product_code,
"distCode": dist_code,
},
)
def check_reach_area(self, product_code: str, dist_code: str,
cust_code: str, weight: str, waybill_no: str) -> dict:
"""校验可达区域。"""
return self._post(
"/pickup-web/a/common/reachareaverification",
data={
"productCode": product_code,
"distCode": dist_code,
"custCode": cust_code,
"weight": weight,
"waybillNo": waybill_no,
},
)
def check_important_address(self, waybill_no: str, addr: str) -> dict:
"""检查重要地址。"""
return self._post(
"/pickup-web/a/postwaybill/importantaddress",
data={"waybillNo": waybill_no, "queryAddr": addr},
)
def check_sensitive_customers(self, mobile_phones: str, waybill_no: str) -> dict:
"""检查敏感客户。"""
return self._post(
"/pickup-web/a/postwaybillargs/findsensitivecustomers",
data={"mobilePhone": mobile_phones, "waybillNo": waybill_no},
)
# ── 杂项查询 ──────────────────────────────────────────────
def query_ecommerce(self, query: str = "B") -> list:
"""查询电商平台列表。"""
return self._post(
"/pickup-web/a/postwaybill/queryecommerce",
data={"query": query},
)
def query_package_sort(self) -> list:
"""查询包装分类。"""
return self._post("/pickup-web/a/common/querypackagesort")
def query_deliver_date(self) -> dict:
"""查询可投递日期。"""
return self._post("/pickup-web/a/postwaybillargs/deliverdate")
def query_reason_temp_storage(self) -> dict:
"""查询暂存原因列表。"""
return self._post("/pickup-web/a/postwaybill/queryreasonfortemporarystorage")
def check_customer_warning(self) -> dict:
"""检查客户预警。"""
return self._post("/pickup-web/a/common/customerwarning")
def check_sheet_warning(self) -> dict:
"""检查面单预警。"""
return self._post("/pickup-web/a/postwaybill/sheetWarning")
# ── 提交 ──────────────────────────────────────────────────
def submit(self, form_data: dict[str, str]) -> dict:
"""提交揽收表单。返回 {"resCode": "", "resMess": "收寄成功", ...}。"""
return self._post(
"/pickup-web/a/postwaybill/isnotetosave",
data=form_data,
)