Add archived/tongtool-y2-lowest-shipping
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
---
|
||||
name: tongtool-y2-lowest-shipping
|
||||
description: Batch process TEMU semi-managed orders in Tongtool ERP to calculate the lowest shipping price, skipping printed/shipped orders, and saving remarks using Web UI + API fallback.
|
||||
---
|
||||
|
||||
# Tongtool TEMU Lowest Shipping Calculator & Remark
|
||||
|
||||
This skill automates the process of finding the lowest shipping method for TEMU semi-managed orders in Tongtool, skipping orders that are already processed, utilizing the TEMU platform API for calculation, and saving the result robustly using a Web UI + API Fallback mechanism.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3
|
||||
- `DrissionPage`
|
||||
- `ddddocr`
|
||||
- `requests`
|
||||
|
||||
## Crucial Platform Integration Discoveries & Pitfalls
|
||||
|
||||
### 1. Tongtool Open API V3 `ordersQuery` Signature Bug
|
||||
When querying `/openapi/tongtool/ordersQuery`, passing date filters (`saleDate`, `payDate`, `updatedDate`, etc.) frequently results in a **`519 sign fail`** or **`525 cannot be all empty`** error. The official V3 signature logic is broken for this specific endpoint when url-encoding date parameters.
|
||||
**Workaround:** Fetch orders from pre-existing local caches (e.g., scraping results), or query by specific `orderId`, or query strictly by `orderStatus: "1"` (Processing) without any date parameters.
|
||||
|
||||
### 2. Business Logic for Skipping Orders (Order Status Rules)
|
||||
Do **not** perform shipping calculations for orders if they meet any of these criteria based on the API `ordersQuery` details response:
|
||||
* `orderStatus` is `shipped` (物流商发货), `completed` (签收), or `cancelled`.
|
||||
* `printCompleteTime` has a value (通途已打印).
|
||||
* `despatchCompleteTime` has a value (通途已发货).
|
||||
* The order already has a remark containing `[H201试算]` (assuming it isn't an error marker).
|
||||
|
||||
### 3. Robust Remark Writing (Web UI + API Fallback)
|
||||
Because Tongtool's Web UI is highly asynchronous, `textarea` injection and Javascript `.click()` on the save button occasionally fails silently. The robust pipeline must:
|
||||
1. Write the remark via Web UI layout injection.
|
||||
2. Wait a few seconds array.
|
||||
3. Verify via API (`/openapi/tongtool/ordersRemarkQuery`) using `orderIdKey`.
|
||||
4. If missing, Fallback to API write (`/openapi/tongtool/addOrderRemark`).
|
||||
|
||||
## The Pipeline Script
|
||||
|
||||
This script logs in, filters and sorts target orders (ascending by the last 4 digits of the order ID), extracts the cheapest price via the shipping analysis popup, and securely writes it back.
|
||||
|
||||
```python
|
||||
import time, json, re, hashlib, requests, os
|
||||
from DrissionPage import ChromiumPage, ChromiumOptions
|
||||
import ddddocr
|
||||
|
||||
ACCESS_KEY = "b4163dfec4654fd480c1cadeafea8ae7"
|
||||
SECRET_KEY = "0fe4f51c1b8840e6bd70394ce6ec74f638a25524decb45b2adbb7ae5d56bfcdf"
|
||||
PARTNER_OPEN_ID = "ec1d0a0ac9bceca98ed62e1504351371"
|
||||
BASE_URL = "https://open.tongtool.com/apiv3-service"
|
||||
|
||||
SUCCESS_PREFIX = '[H201试算]'
|
||||
ERROR_REMARK = '[H201试算]出错没找到最低价'
|
||||
|
||||
def get_token_and_sign():
|
||||
r1 = requests.get(
|
||||
f"https://open.tongtool.com/open-platform-service/devApp/appToken?accessKey={ACCESS_KEY}&secretAccessKey={SECRET_KEY}",
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
app_token = r1.json()["datas"]
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
raw_sign = f"app_token{app_token}timestamp{timestamp}{SECRET_KEY}"
|
||||
sign = hashlib.md5(raw_sign.encode()).hexdigest()
|
||||
return {"app_token": app_token, "timestamp": timestamp, "sign": sign}
|
||||
|
||||
def api_call(path, body):
|
||||
_params = get_token_and_sign()
|
||||
headers = {"Content-Type": "application/json", "api_version": "3.0"}
|
||||
for _ in range(3):
|
||||
try:
|
||||
resp = requests.post(f"{BASE_URL}{path}", params=_params, headers=headers, json=body, timeout=20)
|
||||
return resp.json()
|
||||
except:
|
||||
time.sleep(2)
|
||||
return {}
|
||||
|
||||
def filter_and_sort_orders(raw_orders_list, limit=5):
|
||||
"""Filter out completed/shipped/printed orders, and sort by ID last 4 digits"""
|
||||
unique_orders = list(set(raw_orders_list))
|
||||
valid_pool = []
|
||||
|
||||
for oid in unique_orders:
|
||||
res = api_call("/openapi/tongtool/ordersQuery", {
|
||||
"merchantId": PARTNER_OPEN_ID, "pageNo": "1", "pageSize": "100", "orderId": oid
|
||||
})
|
||||
if res.get("code") != 200 or not res.get("datas", {}).get("array"):
|
||||
continue
|
||||
|
||||
info = res["datas"]["array"][0]
|
||||
|
||||
# 1. Skip based on status and timestamps
|
||||
if info.get("orderStatus") in ["shipped", "completed", "cancelled"]:
|
||||
continue
|
||||
if info.get("printCompleteTime"):
|
||||
continue
|
||||
if info.get("despatchCompleteTime"):
|
||||
continue
|
||||
|
||||
# 2. Skip already processed ones
|
||||
marks_res = api_call("/openapi/tongtool/ordersRemarkQuery", {
|
||||
"merchantId": PARTNER_OPEN_ID, "orderIdKey": info.get("orderIdKey"),
|
||||
"pageNo": "1", "pageSize": "10"
|
||||
})
|
||||
|
||||
already_success = False
|
||||
if marks_res.get("code") == 200 and marks_res.get("datas"):
|
||||
for m in marks_res["datas"].get("array", []):
|
||||
text = m.get("orderRemark", "")
|
||||
if SUCCESS_PREFIX in text and '出错' not in text:
|
||||
already_success = True
|
||||
break
|
||||
|
||||
if already_success: continue
|
||||
valid_pool.append(oid)
|
||||
|
||||
# Sort ascending by the last 4 digits
|
||||
def last_4(o):
|
||||
m = re.search(r'(\\d{4})$', o)
|
||||
return int(m.group(1)) if m else 9999
|
||||
|
||||
valid_pool.sort(key=last_4)
|
||||
return valid_pool[:limit]
|
||||
|
||||
def calculate_temu_lowest_shipping_and_remark(order_id: str, driver):
|
||||
def nuke_popups(page):
|
||||
try:
|
||||
page.run_js("""
|
||||
document.querySelectorAll('.layui-layer-shade, .layui-layer, .modal, .overlay, .jclose-blue, .close, [title="关闭"], .layui-layer-close').forEach(el => { try { el.remove(); } catch(e){} });
|
||||
""")
|
||||
except: pass
|
||||
|
||||
driver.get("https://yijiety.tongtool.com/dashboard/homepage/index.htm")
|
||||
time.sleep(4)
|
||||
nuke_popups(driver)
|
||||
|
||||
search_url = (
|
||||
f"https://yijiety.tongtool.com/search/order.htm"
|
||||
f"?search_text_value={order_id}&search_mark=salesRecordNumber&source=global"
|
||||
)
|
||||
driver.get(search_url)
|
||||
time.sleep(8)
|
||||
nuke_popups(driver)
|
||||
|
||||
def open_shipping_popup():
|
||||
driver.run_js("""
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
if (el.textContent.trim() === '仓储物流' && el.offsetHeight > 0) { el.click(); return true; }
|
||||
}
|
||||
return false;
|
||||
""")
|
||||
time.sleep(3)
|
||||
driver.run_js("""
|
||||
for (const el of document.querySelectorAll('a, button, span')) {
|
||||
if (el.textContent.trim() === '编辑') {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0 && r.height < 80) { el.click(); return true; }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
""")
|
||||
time.sleep(4)
|
||||
clicked_analysis = driver.run_js("""
|
||||
for (const a of document.querySelectorAll('a')) {
|
||||
if (a.textContent.trim() === '邮寄方式解析') { a.click(); return true; }
|
||||
}
|
||||
return false;
|
||||
""")
|
||||
if not clicked_analysis: return False
|
||||
time.sleep(6)
|
||||
return driver.run_js("return !!document.querySelector('div[windowid] #tabpanelDiv');")
|
||||
|
||||
if not open_shipping_popup(): return None
|
||||
|
||||
def extract_temu_cheapest():
|
||||
try:
|
||||
driver.run_js("""
|
||||
const popups = document.querySelectorAll('div[windowid]');
|
||||
let popup = null;
|
||||
for (const p of popups) { if (p.querySelector('#tabpanelDiv')) { popup = p; break; } }
|
||||
if (!popup) return;
|
||||
const tab = popup.querySelector('li[tabtitle="TEMU平台接口试算"]');
|
||||
if (tab) tab.click();
|
||||
""")
|
||||
time.sleep(4)
|
||||
driver.run_js("""
|
||||
const popups = document.querySelectorAll('div[windowid]');
|
||||
let popup = null;
|
||||
for (const p of popups) { if (p.querySelector('#tabpanelDiv')) { popup = p; break; } }
|
||||
if (!popup) return;
|
||||
const temuDiv = popup.querySelector('#temuShippingMethod');
|
||||
if (!temuDiv || temuDiv.classList.contains('hide')) return;
|
||||
for (const b of temuDiv.querySelectorAll('a, button')) {
|
||||
if (b.textContent.includes('查询') || b.textContent.includes('试算')) { b.click(); return; }
|
||||
}
|
||||
""")
|
||||
time.sleep(8)
|
||||
prices = driver.run_js("""
|
||||
const popup = Array.from(document.querySelectorAll('div[windowid]')).find(p => p.querySelector('#tabpanelDiv'));
|
||||
if (!popup) return [];
|
||||
const temuDiv = popup.querySelector('#temuShippingMethod') || popup;
|
||||
const out = [];
|
||||
for (const tr of temuDiv.querySelectorAll('tr')) {
|
||||
const tds = tr.querySelectorAll('td');
|
||||
if (tds.length < 3) continue;
|
||||
const rowText = tr.textContent || '';
|
||||
|
||||
let priceMatch = rowText.match(/\\$\\s*(\\d+\\.\\d{2})/);
|
||||
let currency = '$';
|
||||
if (!priceMatch) {
|
||||
priceMatch = rowText.match(/[¥¥]\\s*(\\d+\\.\\d{2})/);
|
||||
currency = '¥';
|
||||
}
|
||||
if (!priceMatch) continue;
|
||||
|
||||
// Skip disabled options ("未启用") entirely
|
||||
if (rowText.includes('未启用')) continue;
|
||||
|
||||
const price = parseFloat(priceMatch[1]);
|
||||
if (isNaN(price) || price <= 0) continue;
|
||||
|
||||
let carrier = '', method = '';
|
||||
for (const td of tds) {
|
||||
const t = td.textContent.trim();
|
||||
if (!t) continue;
|
||||
if (t.includes(priceMatch[0])) continue;
|
||||
if (!carrier) { carrier = t; continue; }
|
||||
if (!method) { method = t; break; }
|
||||
}
|
||||
if (!carrier && !method) continue;
|
||||
out.push({carrier, method, price, raw: priceMatch[0], currency});
|
||||
}
|
||||
return out;
|
||||
""")
|
||||
if not prices: return None
|
||||
return min(prices, key=lambda x: x['price'])
|
||||
except Exception as e: return None
|
||||
|
||||
cheapest = extract_temu_cheapest()
|
||||
|
||||
driver.run_js("""
|
||||
document.querySelectorAll('.jclose-blue, .close, [title="关闭"], div[windowid] .layui-layer-close').forEach(b => { try { b.click(); } catch(e){} });
|
||||
document.querySelectorAll('div[windowid], .layui-layer, .layui-layer-shade').forEach(e => e.remove());
|
||||
""")
|
||||
time.sleep(2)
|
||||
|
||||
if cheapest:
|
||||
c = (cheapest.get('carrier') or '').strip()
|
||||
m = (cheapest.get('method') or '').strip()
|
||||
label = f"{c}>>{m}" if c and m else (c or m)
|
||||
remark = f"{SUCCESS_PREFIX}{label} {cheapest['currency']}{cheapest['price']:.2f}"
|
||||
|
||||
# --- NEW LOGIC: Update order's actual shipping method in Tongtool based on lowest price ---
|
||||
# NOTE: Tongtool automatically handles binding the top-level logistics provider (e.g. 湖北众信 for US East, 易世通达 for US West).
|
||||
# We MUST NOT change the top-level provider. We only select the correct last-mile option (USPS, GOFO, Swift, etc.) associated with that existing provider.
|
||||
def get_best_method_code(method_str):
|
||||
# Method mapping: match common last-mile carrier names
|
||||
method_upper = method_str.upper()
|
||||
sub_method = None
|
||||
if "USPS" in method_upper:
|
||||
sub_method = "USPS"
|
||||
elif "GOFO" in method_upper:
|
||||
sub_method = "GOFO"
|
||||
elif "SWIFT" in method_upper:
|
||||
sub_method = "SwiftX" if "SWIFTX" in method_upper else "Swift"
|
||||
elif "SPEED" in method_upper:
|
||||
sub_method = "SpeedX" if "SPEEDX" in method_upper else "Speed"
|
||||
else:
|
||||
return method_str
|
||||
|
||||
return sub_method
|
||||
|
||||
def update_shipping_method(driver, best_sub_method):
|
||||
if not best_sub_method:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Check current selected method first
|
||||
current_method_full = driver.run_js("""
|
||||
// The shipping method text input
|
||||
const inputs = document.querySelectorAll('input.layui-input');
|
||||
for (const input of inputs) {
|
||||
if (input.value.includes('>>')) {
|
||||
return input.value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
""")
|
||||
|
||||
if current_method_full and best_sub_method.upper() in current_method_full.upper():
|
||||
return True # Already correct
|
||||
|
||||
# Parse current provider to stay within it (e.g. "湖北众信" or "易世通达")
|
||||
current_provider = None
|
||||
if current_method_full:
|
||||
if "湖北众信" in current_method_full:
|
||||
current_provider = "湖北众信"
|
||||
elif "易世通达" in current_method_full:
|
||||
current_provider = "易世通达"
|
||||
|
||||
if not current_provider:
|
||||
# If we can't extract the current provider, we can't safely ensure we stay within it.
|
||||
return False
|
||||
|
||||
# Find dropdown trigger and click it
|
||||
opened = driver.run_js("""
|
||||
const inputs = document.querySelectorAll('input.layui-input');
|
||||
for(const input of inputs) {
|
||||
if(input.value.includes('>>') || input.placeholder.includes('方式')) {
|
||||
input.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
""")
|
||||
if not opened: return False
|
||||
time.sleep(1)
|
||||
|
||||
# Click the option (MUST contain both the current provider and the new best sub_method)
|
||||
clicked = driver.run_js(f"""
|
||||
const items = document.querySelectorAll('dd');
|
||||
for (const item of items) {{
|
||||
const text = item.textContent.toUpperCase();
|
||||
if (text.includes('{current_provider}') && text.includes('{best_sub_method.upper()}')) {{
|
||||
item.click();
|
||||
return true;
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
""")
|
||||
|
||||
if clicked:
|
||||
# Save changes for the entire order
|
||||
driver.run_js("""
|
||||
for (const b of document.querySelectorAll('button')) {
|
||||
if (b.textContent.trim() === '保存' && !b.closest('#orderRemarkForm')) {
|
||||
b.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
""")
|
||||
time.sleep(2)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
target_sub_method = get_best_method_code(m)
|
||||
update_shipping_method(driver, target_sub_method)
|
||||
# -----------------------------------------------------------------------------------------
|
||||
|
||||
else: remark = ERROR_REMARK
|
||||
|
||||
remark_btn = driver.ele('css:#addOrderRemark')
|
||||
if remark_btn:
|
||||
a_tag = remark_btn.ele('tag:a')
|
||||
if a_tag:
|
||||
a_tag.click()
|
||||
time.sleep(2)
|
||||
textarea = driver.ele('tag:textarea')
|
||||
if textarea:
|
||||
js = f'''
|
||||
var ta = document.querySelector('textarea');
|
||||
if(ta) {{ ta.value = '{remark}'; ta.dispatchEvent(new Event('input', {{bubbles: true}})); ta.dispatchEvent(new Event('change', {{bubbles: true}})); }}
|
||||
'''
|
||||
driver.run_js(js)
|
||||
time.sleep(1)
|
||||
for a in driver.eles('tag:a'):
|
||||
if a.text.strip() == '保存':
|
||||
a.click(by_js=True)
|
||||
time.sleep(3)
|
||||
break
|
||||
return remark
|
||||
|
||||
# Example usage integrating verify & fallback...
|
||||
# order_id_key can be fetched via generic ordersQuery beforehand.
|
||||
# remark = calculate_temu_lowest_shipping_and_remark(order_id, driver)
|
||||
# -> Verify -> api_call("/openapi/tongtool/ordersRemarkQuery", ...)
|
||||
# -> Fallback -> api_call("/openapi/tongtool/addOrderRemark", ...)
|
||||
```
|
||||
Reference in New Issue
Block a user