--- name: tongtool-temu-shipping-calc description: Playwright automation for Tongtool ERP TEMU semi-managed orders — filter pre-print orders WITHOUT existing remarks, login with captcha retry, iterate through each order, open 仓储物流 → 邮寄方式解析 → TEMU平台接口试算, click 查询, find cheapest shipping price, write remark to order (or error remark on failure), and support scheduled looping. --- # Tongtool TEMU Shipping Cost Calculator (v2) End-to-end automation: **find pre-print (未完成通途打印) TEMU half-managed orders without remarks** → compute cheapest TEMU API shipping price → write remark back to order → repeat on schedule. ## Core Requirements (User Spec) 1. **筛选目标**:未完成通途打印步骤 **且** 没有备注 的 TEMU 半托管订单 2. **逐个处理**:订单详情 → 仓储物流 → 邮寄方式解析 → **TEMU平台接口试算**(⚠️ 不是运费模板试算)→ 点击查询 → 找出最低价 3. **写入备注**: - 成功:格式如 `GOFO,Standard 预估$2.50` - 失败:写入 `出错没找到最低价` 4. **定时循环**:支持 cron/systemd 定时重复执行 ## Prerequisites - `playwright` + Chromium at `/home/ubuntu/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome` - `ddddocr` for captcha OCR - `requests` for captcha image download - Tongtool account: `ozonezsjgw@163.com` / `111111` ## 1. Login with Captcha Retry **Critical fix**: `ddddocr` may return CJK symbols/non-printable chars. Filter to ASCII alphanumeric before character substitution. ```python import asyncio, re, subprocess, requests from playwright.async_api import async_playwright USERNAME = 'ozonezsjgw@163.com' PASSWORD = '111111' BROWSER_PATH = '/home/ubuntu/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome' def correct_ocr(raw): cleaned = ''.join(c for c in raw if c.isascii() and c.isalnum()) return cleaned.translate(str.maketrans('oliOSsZz', '01105522')) async def do_login(page, context): await page.goto('https://passport.tongtool.com/') await asyncio.sleep(3) await page.fill('input[name="username"]', USERNAME) await page.fill('input[name="password"]', PASSWORD) for attempt in range(5): img = page.locator('img#captcha') src = await img.get_attribute('src') if src.startswith('/'): src = 'https://passport.tongtool.com' + src cookies = await context.cookies() cookie_dict = {c['name']: c['value'] for c in cookies} resp = requests.get(src, cookies=cookie_dict, timeout=10) raw = subprocess.check_output(['python3', '-c', f""" import ddddocr print(ddddocr.DdddOcr(show_ad=False).classification({resp.content!r})) """]).decode().strip() code = correct_ocr(raw) if len(code) < 4: await img.click() await asyncio.sleep(2) continue await page.fill('input[name="captcha"]', code) await page.click('button.btn-primary') await asyncio.sleep(8) if 'member' in page.url: return True return False ``` ## 2. Navigate + Select Warehouse ```python async def goto_wait_stock(page): await page.goto('https://yijiety.tongtool.com/warehouse/assignStockSelfMaintenance/index.htm') await asyncio.sleep(30) # Open warehouse dropdown await page.evaluate(""" var spans = document.querySelectorAll('span'); for (var s of spans) { if (s.textContent.trim() === '请选择仓库:') { s.parentElement.click(); break; } } """) await asyncio.sleep(3) await page.evaluate("document.querySelector('td[title=\"武汉仓库\"]').click()") await asyncio.sleep(15) # Click 查询 await page.evaluate(""" var all = document.querySelectorAll('a, button, span'); for (var i=0; i { var rows = document.querySelectorAll('tr, div.grid-row, div.x-grid3-row'); var results = []; for (var row of rows) { var orderNo = ''; // Remove whitespace from textContent to prevent regex breaking on visually wrapped long IDs for (var cell of row.querySelectorAll('td, div.cell')) { var txt = (cell.textContent || '').replace(/\\s+/g, ''); var m = txt.match(/([A-Za-z0-9]+-PO-\\d+-\\d+)/i); if (m) { orderNo = m[1]; break; } } if (!orderNo) continue; // Idempotency: skip if already processed var rowText = row.textContent || ''; var hasRemark = rowText.includes('[TEMU试算]') || rowText.includes('出错没找到最低价'); if (hasRemark) continue; results.push(orderNo); } return results; }""") # Deduplicate seen = set() unique = [] for o in targets: if o not in seen: seen.add(o) unique.append(o) print(f'Found {len(unique)} pre-print, un-remarked TEMU half-managed orders') return unique ``` > **Note**: Exact selectors for order status and remark columns may vary. If the default logic misses, use DevTools to find the specific column class/index and adjust the `rowText` check to read targeted `tds[idx]` instead. ## 4. Open 邮寄方式解析 Popup (per order) **Critical**: 编辑 button requires exact text match `=== '编辑'` + visibility check. `includes('编辑')` matches invisible nodes. ```python async def open_shipping_popup(page, order_no): # Close residual popups await page.evaluate(""" var closeBtns = document.querySelectorAll('.jclose-blue, .close, [title="关闭"], .layui-layer-close'); for (var i=0; i {{ var links = document.querySelectorAll('a'); for (var i=0; i { var all = document.querySelectorAll('*'); for (var i=0; i 0) { all[i].click(); return; } } }""") await asyncio.sleep(8) # Click 编辑 — exact match + visibility await page.evaluate("""() => { var all = document.querySelectorAll('a, button, span'); for (var i=0; i 0 && rect.height > 0) { all[i].click(); return; } } } }""") await asyncio.sleep(8) # Click 邮寄方式解析 opened = await page.evaluate("""() => { var all = document.querySelectorAll('a'); for (var i=0; i` - **Must click `TEMU平台接口试算` tab** (NOT `运费模板试算`) - After switching tab, **must click 查询** button inside `#temuShippingMethod` to trigger API call - Prices come back as e.g. `GOFO` (carrier) / `Standard` (method) / `$2.50` (USD price) ```python async def extract_temu_cheapest(page): """Returns string like 'GOFO,Standard 预估$2.50' or None on failure.""" # Step 1: Switch to TEMU tab switched = await page.evaluate("""() => { var popups = document.querySelectorAll('div[windowid]'); var popup = null; for (var p of popups) { if (p.querySelector('#tabpanelDiv')) { popup = p; break; } } if (!popup) return false; var tab = popup.querySelector('li[tabtitle="TEMU平台接口试算"]'); if (tab) { tab.click(); return true; } return false; }""") if not switched: return None await asyncio.sleep(10) # Step 2: Click 查询 button inside #temuShippingMethod await page.evaluate("""() => { var popups = document.querySelectorAll('div[windowid]'); var popup = null; for (var p of popups) { if (p.querySelector('#tabpanelDiv')) { popup = p; break; } } if (!popup) return; var temuDiv = popup.querySelector('#temuShippingMethod'); if (temuDiv && !temuDiv.classList.contains('hide')) { var btns = temuDiv.querySelectorAll('a, button'); for (var b of btns) { if (b.textContent.includes('查询') || b.textContent.includes('试算')) { b.click(); return; } } } }""") await asyncio.sleep(12) # Step 3: Extract all carrier/method/price triples and find cheapest prices = await page.evaluate("""() => { var popups = document.querySelectorAll('div[windowid]'); var popup = null; for (var p of popups) { if (p.querySelector('#tabpanelDiv')) { popup = p; break; } } if (!popup) return []; var temuDiv = popup.querySelector('#temuShippingMethod'); if (!temuDiv) return []; var results = []; var rows = temuDiv.querySelectorAll('tr'); for (var row of rows) { var tds = row.querySelectorAll('td'); if (tds.length < 3) continue; // Try to find USD price ($x.xx) in the row var rowText = row.textContent; var currency = '$'; var priceMatch = rowText.match(/\\$\\s*(\\d+\\.\\d+)/); if (!priceMatch) { priceMatch = rowText.match(/[¥¥]\\s*(\\d+\\.\\d+)/); currency = '¥'; } if (!priceMatch) continue; // Skip disabled options ("未启用") entirely if (rowText.includes('未启用')) continue; // carrier usually in td[1] or td[0], method in td[2] var carrier = '', method = ''; for (var td of tds) { var t = td.textContent.trim(); if (!t || t === priceMatch[0]) continue; if (!carrier) { carrier = t; continue; } if (!method) { method = t; break; } } if (!carrier && !method) continue; results.push({ carrier: carrier, method: method, currency: currency, price: parseFloat(priceMatch[1]) }); } return results; }""") if not prices: return None cheapest = min(prices, key=lambda x: x['price']) # Format: 'GOFO,Standard 预估$2.50' # Fallback to handle missing carrier c = (cheapest.get('carrier') or '').strip() m = (cheapest.get('method') or '').strip() label = f"{c}>>{m}" if c and m else (c or m) return f"{label} {cheapest['currency']}{cheapest['price']:.2f}" ``` ## 6. Add Order Remark (success OR error) ```python async def add_remark(page, remark_text): """Add remark text (success price OR error message) to the open order.""" clicked = await page.evaluate("""() => { var btn = document.querySelector('#addOrderRemark'); if (btn) { var a = btn.querySelector('a'); if (a) { a.click(); return true; } } return false; }""") if not clicked: return False await asyncio.sleep(5) # Fill textarea (escape single quotes in remark_text) escaped = remark_text.replace("'", "\\'").replace('"', '\\"') await page.evaluate(f"""() => {{ var ta = document.querySelector('textarea'); if (ta) {{ ta.value = '{escaped}'; ta.dispatchEvent(new Event('input', {{bubbles:true}})); ta.dispatchEvent(new Event('change', {{bubbles:true}})); }} }}""") await asyncio.sleep(1) # Click 保存 saved = await page.evaluate("""() => { var links = document.querySelectorAll('a, button'); for (var i=0; i 0`. Naive `includes('编辑')` hits invisible DOM. 7. **Popup is `div[windowid]`, not iframe**: Random `windowid` attribute, lives in main page DOM. Search by `#tabpanelDiv` structure, not by hardcoded ID. 8. **查询 button inside #temuShippingMethod must be clicked after tab switch**: Tab switch alone doesn't trigger API call. 9. **Dispatch input/change events after setting textarea value**: React/Vue components won't detect `.value = ...` assignment without events. Use `dispatchEvent(new Event('input'))`. 10. **Warehouse selection**: dropdown is custom component — click label parent, then `td[title="武汉仓库"]`. 11. **Generous waits between actions**: SPA uses ExtJS-style rendering; use 8–15s between clicks for reliable popup/tab rendering. 12. **Session 2026-04-28 test**: Successfully logged in and processed 2 TEMU half-managed orders out of 50 total orders. 13. **2026-04-28 单轮实测(limit=3)**: 登录一次过(captcha='35cn2'),目标订单 1 个 `ALuckhalf-PO-211-17213720356471367`,成功写入备注 `[TEMU试算]USPS,Ground Advantage $3.32`(原始文本 `预估$3.32; 2-5 个工作日送达`)。脚本:`/home/ubuntu/scripts/tongtool_temu_once.py`(用法 `python3 scripts/tongtool_temu_once.py [limit]`)。 14. **carrier 列可能为空**: 某些 TEMU 平台返回的表只有 method 列而 carrier 列空,格式化备注时要做兜底 `label = f"{c}>>{m}" if c and m else (c or m)`,否则备注变成 `>>USPS,...` 这种首字段空的难看格式。 15. **避免 ddddocr subprocess 启动**: 用 subprocess 把图片字节 `{resp.content!r}` 作为参数拼进 python -c 命令会传超大二进制,极易失败。应在主进程直接 `import ddddocr; ocr.classification(bytes)`。venv `/home/ubuntu/.hermes/hermes-agent/venv/bin/python3` 已装 ddddocr。 16. **运行结果持久化**: 每轮输出 `/home/ubuntu/tongtool_temu_runs/run_.json`,便于事后审计和诊断。 17. **Order Number Extraction**: Use `textContent` and `.replace(/\s+/g, '')` instead of `innerText`. Tongtool inserts visual line breaks in long order numbers (e.g., `2ALuckhalf...726`), which breaks regex matching if whitespace isn't stripped. 18. **Order Number Pattern**: TEMU half-managed orders do NOT always contain the word `half` (e.g., `88NewGrou-PO-...`). Use `([A-Za-z0-9]+-PO-\d+-\d+)` to match all of them. 19. **Status Filter**: Removed DOM-based status checks per user preference. Rely purely on idempotency (checking for `[TEMU试算]` or `出错没找到最低价`) to determine if an order needs processing. 20. **Tongtool 订单状态生命周期**: 订单状态依次流转为:`订单生成` → `付款` → `检查` → `配货` → `物流商下单` → `通途打印` → `通途发货` → `物流商发货` → `签收`。筛选时,若要处理发货前的订单,需排查包含 `通途打印` 及后续状态的关键字,而不能仅依赖 `已发货`,因为“通途打印”标志着仓库作业已实质开始。 21. **⚠️ PASSWORD 被占位符替换导致登录失败**: 脚本文件(如 `tongtool_temu_once_v2.py`)中的 `PASSWORD='***'` 是脱敏占位符,实际运行时会被拒绝。若 OCR 返回的验证码看起来正常(如 `g72fg`、`55m38d`)但登录反复失败,先用 `grep -n "PASSWORD"` 检查是否为 `'***'` 或 `'111111'`。可通过以下命令批量检查目录下所有脚本:`grep -rn "PASSWORD.*='\\*\\*\\*'" /home/ubuntu/scripts/`。 22. **浏览器启动参数需加 `ignore_https_errors=True`**: `context = await browser.new_context(ignore_https_errors=True)` 避免 Tongtool 子域(如 `yijiety.tongtool.com`)的证书问题阻断页面加载。 23. **`goto_wait_stock` 需等待 30 秒**: 待配货页 (`assignStockSelfMaintenance/index.htm`) 是重度 SPA,首次加载后 ExtJS 组件渲染耗时约 20-30 秒。`await asyncio.sleep(30)` 后再操作仓库下拉框,否则选择器可能匹配不到 DOM。 24. **单脚本文件路径**: 当前稳定版本为 `/home/ubuntu/scripts/tongtool_temu_once_v2.py`,用法:`python3 scripts/tongtool_temu_once_v2.py [limit]`。运行结果自动写入 `/home/ubuntu/tongtool_temu_runs/run_.json`。 25. **"未启用" (Disabled) Shipping Options**: TEMU API often returns shipping methods that are disabled ("未启用") and completely lack carrier/method text, causing them to be parsed with empty labels (e.g., `>>undefined`) but sometimes containing valid prices. The DOM scraper must explicitly check `rowText.includes('未启用')` and skip the row, and also skip saving entries where both `carrier` and `method` are empty. 26. **OpenAPI Pagination Bypass**: To process batches of orders without getting stuck on paginated UI containing already-handled orders, query `open.tongtool.com/apiv3-service/openapi/tongtool/ordersQuery` (using `merchantId`, `pageNo`, and `pageSize`) to get a clean list of orders first (filtering out ones with `[TEMU试算]` in remark), sort them by the last 4 digits of order ID, and feed this target list to the UI automation script.