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