Files
atomk-hermes-skills/skills/misc/temu-pipeline/SKILL.md
T
2026-07-10 16:10:13 +08:00

407 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: temu-pipeline
description: TEMU半托管订单全流程流水线 — 从API获取武汉仓库TEMU半托管订单,过滤[H201试算]/[A2试算]已处理订单,逐个试算最低运费,API+UI双策略修改物流,通过API写[H201试算]备注并验证。含EPIPE防护、carrier解析修复、分批处理。
category: ecommerce
---
# TEMU 半托管订单全流程流水线 [H201试算]
一键完成:API获取武汉仓库半托管订单 → 排序 → 逐个试算最低运费 → API动态匹配物流(失败则UI兜底) → API写备注 → 验证重试。
## 依赖
```bash
pip install playwright ddddocr requests
playwright install chromium
```
## 使用
```bash
# 推荐版本 — 纯Playwrightiframe穿透稳定
python3 scripts/pipeline_hybrid.py
# 旧版本 — DrissionPageiframe穿透不稳定,编辑按钮成功率<2%
python3 scripts/pipeline_dp.py
```
## 关键发现(会话经验总结)
### 武汉仓库
- 名称: `武汉仓库`
- warehouseId: `0006000951201403050000004680`
- warehouseType: `owner`
### 获取TEMU半托管订单(最关键参数)
```python
api_call("/openapi/tongtool/ordersQuery", {
"merchantId": PARTNER_OPEN_ID,
"pageNo": "1", "pageSize": "100",
"warehouseId": "0006000951201403050000004680",
"saleMode": "2", # 2=半托管
"platformCode": "temu_api", # ⚠️ 必须加!否则不返回TEMU订单
"orderStatus": "waitPacking", # waitPacking/error
"payDateFrom": "...", "payDateTo": "..."
})
```
**关键字段:**
- `orderIdCode` — 外部完整订单号(如 `4Caohalf-PO-211-13340762327670771`
- `orderIdKey` — 通途内部ID(用于写备注、改物流)
- `salesRecordNumber` — 通途短单号(如 `PO-211-13340762327670771`
- `dispathTypeName` — 当前物流方式(如 `EJET-GOFO(美东)`
> ⚠️ `orderId` 字段为空!外部单号在 `orderIdCode` 中,不在 `orderId`。
> `platformOrdersQuery` 的 `salesRecordNumber` 返回纯数字,不带 `-PO-` 格式。
### 过滤TEMU订单
```python
temu_orders = [o for o in all_orders if '-PO-211-' in (o.get('orderIdCode', '') or '')]
```
### TEMU试算表格结构(2026年新版)
```
表头: ['', 'merchant_shipping_method_param_id', 'Temu邮寄方式', '平台预估参数', '系统邮寄方式名称', ...]
```
- TD0: 空(跳过)
- TD1: 数字ID(跳过)
- TD2: **Temu邮寄方式名**`SwiftX,Standard` / `SpeedX,standard`(当carrier用)
- TD3: 预估价格 — `预估$2.42; 2-5 天送达`(提取价格)
- TD4: 系统名称 — `TEMU半托管美西>>...`(含>>,跳过)
**carrier/method解析规则:**
1. 跳过:纯数字ID(8位+)、价格列(含`预估`)、含`>>`列、序号列
2. carrier = 第一个非跳过的TD
3. method = carrier中逗号前部分 (`SwiftX,Standard``SwiftX`)
### 搜索结果页"编辑"按钮
通途详情页需要先点击 **"仓储"链接**(搜索结果页的子tab,非文字"仓储物流"),仓储内容在**iframe**中加载。需在iframe内查找"编辑"和"邮寄方式解析"。
```javascript
// 正确方式
document.querySelectorAll('a').forEach(a => {
if (a.textContent.trim() === '仓储' && a.href.includes('search_text_value')) a.click();
});
// 在iframe内操作
document.querySelectorAll('iframe').forEach(iframe => {
try {
const idoc = iframe.contentDocument || iframe.contentWindow.document;
// idoc.querySelectorAll('a') 找"编辑"、"邮寄方式解析"
} catch(e) {} // 跨域iframe抛异常
});
```
### 物流修改判断逻辑(⚠️ 常见错误)
```python
# ❌ 错误:与试算结果的carrier对比(carrier是TEMU建议值,不是订单实际物流)
carrier_text = cheapest.get("carrier", "")
if best_last_mile.upper() not in carrier_text.upper():
# 这行判断的是"试算建议的末端不在建议的carrier名中"——永远为False
# 即使当前物流是SWIFTX,建议是GOFO,Standard,也会误判为"已是最优"
# ✅ 正确:与订单当前实际物流(dispathTypeName)对比
dispatch = order.get('dispathTypeName', '') or ''
if best_last_mile.upper() not in dispatch.upper():
# dispatch = "EJET-SWIFTX(美东)" → "GOFO" not in "EJET-SWIFTX(美东)" → True → 需要修改
```
```python
# 策略1: API匹配(优先)
sid = find_shipping_method_id(shipping_methods, carrier_keyword, best_last_mile)
if sid:
update_shipping_method_api(oik, sid, order_code)
# 策略2: UI兜底(API匹配不到时)
ui_ok = await update_shipping_in_ui(page, shipping_methods, best_last_mile)
```
关于 `find_shipping_method_id` 匹配逻辑:`last_mile_upper in name`,即 `"GOFO" in "EJET-GOFO(美东)"` → 可能匹配失败因为SHORTNAME格式不同(含地区括号等)。此时用UI兜底直接打开下拉框选择。
### DrissionPage vs Playwright(关键经验 2026-05-02
**DrissionPage iframe穿透极不稳定** — 编辑按钮点击成功率<2%(79/85失败)。根本原因:DP的`get_frames()`和元素定位在通途嵌套iframe中经常找不到元素。
**CDP共享方案不可行** — 尝试让Playwright启动浏览器(`--remote-debugging-port=9222`),然后DP连接同一浏览器,但DP 4.1.1.2的`existing_only`参数不起作用,总是尝试启动新浏览器。
**纯Playwright方案(pipeline_hybrid.py** — 用`page.evaluate()`穿透iframe查找和操作元素,编辑按钮成功率**100%**(66/66)。这是当前推荐方案。
```
DP版(Batch2): 编辑按钮 1/85 成功 (<2%) — iframe穿透彻底失败
PW版(Hybrid): 编辑按钮 66/66 成功 (100%) — iframe穿透完美
```
**Hybrid v2 批量测试统计(100订单):**
- 编辑成功: 71/86 (83%) — 15个失败是iframe内找不到编辑按钮
- 试算有结果: 50/71 (70%)
- 备注写入验证: 50/50 (100%)
- API改物流失败: 23次"订单不存在" — orderUpdate对TEMU半托管不可用(已确认)
**Round2 修复后统计(23订单,直接点击试算行):**
- UI改物流成功: 22/23 (95.7%)
- 唯一失败: 仓储tab找不到(订单可能已移出待包装状态)
**结论**:UI改物流应直接点击试算弹窗中的行,不要尝试关闭弹窗再操作下拉框。API改物流对TEMU半托管订单不可用,需走UI。
### UI改物流:直接在试算弹窗选择(2026-05-02 关键修复)
**错误方案**:关闭试算弹窗 → 在编辑表单操作下拉框
**实测结果**:关闭试算弹窗时,**编辑表单弹窗也一并关闭**(通途layui容器共享)。日志特征:`当前弹窗状态: {'windows': [], 'layers': 0}`
**正确方案**:直接在试算弹窗表格中点击目标物流行,自动应用到编辑表单
**实测结果**22/23 成功 (95.7%)
```python
async def pw_update_shipping(page, best_last_mile):
"""在试算弹窗里直接选择目标物流(不关闭弹窗)"""
best_sub = get_best_method_code(best_last_mile)
# 直接点击试算结果中的匹配行
clicked = await page.evaluate(f"""(target) => {{
const popup = Array.from(document.querySelectorAll('div[windowid]'))
.find(p => p.querySelector('#tabpanelDiv'));
if (!popup) return 'no_popup';
const temuDiv = popup.querySelector('#temuShippingMethod') || popup;
for (const tr of temuDiv.querySelectorAll('tr')) {{
const rowText = tr.textContent || '';
if (!rowText.toUpperCase().includes(target.toUpperCase())) continue;
if (rowText.includes('未启用')) continue;
// 找"选择"按钮或直接点击行
for (const btn of tr.querySelectorAll('a, button, span')) {{
if (['选择', '确定', '应用'].includes(btn.textContent.trim())) {{
btn.click(); return 'clicked_select:' + target;
}}
}}
tr.click(); return 'clicked_row:' + target; // 行本身可点击
}}
return 'not_found:' + target;
}}""", best_sub)
return clicked.startswith('clicked')
```
**调用时机**:试算提取`pw_extract_cheapest()`后直接调用`pw_update_shipping()`,无需关闭弹窗。
**失败情况**:目标物流在试算结果中不存在(渠道未启用、无匹配行)。此时回退到关闭弹窗尝试下拉框(成功率低)。
### 文件锁机制(防止多实例并发)
```python
LOCK_FILE = "/tmp/temu_hybrid_v2.lock"
def acquire_lock():
"""获取文件锁,防止多实例并发运行"""
try:
fd = open(LOCK_FILE, "w")
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
fd.write(f"{os.getpid()}\n") # 写入PID便于调试
fd.flush()
return fd
except (IOError, OSError):
try:
fd.close()
except:
pass
return None
except Exception as e:
print(f"获取锁异常: {e}")
return None
def release_lock(fd):
"""释放文件锁并删除锁文件"""
try:
if fd:
fcntl.flock(fd, fcntl.LOCK_UN)
fd.close()
except:
pass
# 始终尝试删除锁文件
try:
if os.path.exists(LOCK_FILE):
os.remove(LOCK_FILE)
except:
pass
```
**关键点**`release_lock()` 必须删除锁文件。`fcntl.flock` 是进程级的,进程异常退出时锁自动释放,但锁文件不会自动删除。残留的锁文件虽然不会阻止新进程获取flock,但会造成混淆。
**主函数结构**
```python
async def main():
lock_fd = acquire_lock()
if lock_fd is None:
print("已有实例在运行")
return
try:
# ... 主逻辑 ...
finally:
release_lock(lock_fd) # 确保异常时也释放
```
### 日志归档(每次运行保存独立日志)
```python
LOG_DIR = os.path.expanduser("~/.hermes/logs/temu-pipeline")
LOG_FILE = os.path.join(LOG_DIR, f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log")
class TeeLogger:
"""同时输出到文件和stdout"""
def __init__(self, log_file):
os.makedirs(os.path.dirname(log_file), exist_ok=True)
self.terminal = sys.stdout
self.log = open(log_file, 'w', encoding='utf-8')
def write(self, message):
self.terminal.write(message)
self.log.write(message)
self.log.flush()
def flush(self):
self.terminal.flush()
self.log.flush()
def close(self):
self.log.close()
def setup_logging():
sys.stdout = TeeLogger(LOG_FILE)
print(f"日志归档: {LOG_FILE}\n")
```
**使用**
```python
async def main():
setup_logging() # 放在最前面
# ... 后续print都会同时写入文件和stdout ...
```
**查看历史统计**
```bash
grep '完成!' ~/.hermes/logs/temu-pipeline/*.log
```
**日志位置**`~/.hermes/logs/temu-pipeline/run_YYYYMMDD_HHMMSS.log`
### Playwright EPIPE崩溃
长时间运行(15+订单)导致Pipe断开。**不要关闭browser**,只导航到登录页重新认证:
```python
BATCH_SIZE = 15
for batch_start in range(0, len(orders), BATCH_SIZE):
if batch_start > 0:
await page.goto(TT_LOGIN_URL) # 不close browser
# 重新登录...
```
### 物流更新API需传orderId
`orderUpdate` 需要 `orderId`(外部单号),否则报 `参数orderId不能为空!`
```python
api_call("/openapi/tongtool/orderUpdate", {
"orderIdKey": oik, "shippingMethodId": sid, "orderId": order_code
})
```
### 等待配货页面 headless 问题
- 统计功能正常(能显示订单数)
- **查询按钮点击返回0条**(headless下事件链不完整)
- **解决方案**:直接用API获取订单数据,完全绕开浏览器
### API限速
每分钟最多5次,超限返回 `请求次数限额[1Minute/5times.]`。需加3秒间隔+限速后sleep 15秒。
## 完整流程
1. API获取邮寄方式列表 — `getShippingMethod`
2. `ordersQuery` 获取订单(`warehouseId` + `saleMode=2` + `platformCode=temu_api`
3. 筛选 `waitPacking`/`error` 状态 + `-PO-211-` 格式
4. `ordersRemarkQuery` 批量查备注 → 过滤有 `[H201试算]`/`[A2试算]` 的订单
5. 分批(每15个)处理:
- 搜索详情页 → 仓储链接 → 编辑(iframe) → 邮寄方式解析(iframe) → TEMU试算
- 提取最低价(新表头规则)
- **物流修改判断:对比 `dispathTypeName`(订单当前物流)与 `best_last_mile`(试算最优末端)**
- 如果当前物流已含最优末端 → 跳过
- 否则:**API匹配 → `orderUpdate` → 失败则UI兜底**
- 注意:`orderUpdate``orderId` 参数传外部单号(`4Caohalf-PO-211-xxx`)但TEMU订单可能报`订单不存在!`API限制)
- **UI下拉框匹配规则**:只匹配末端派送名(`"GOFO"`),不匹配物流商(`"湖北众信"`)——选项dd元素不含物流商前缀
- API写 `[H201试算]` 备注 → `verify_remark` 验证 → 重试
### 物流修改 vs 备注写入的分离
| 操作 | 方式 | 成功条件 |
|------|------|---------|
| 改物流 | API `orderUpdate` + UI兜底 | API: 需要orderId字段(TEMU订单可能为空);UI: 需要下拉正确弹出 |
| 写备注 | API `addOrderRemark` | 只需要orderIdKey,总是可用 |
**关键策略**:改物流和写备注是**两个独立步骤**。即使物流修改失败,也**必须写备注**。`
`
## 去重逻辑
```python
has_remark = any('[H201试算]' in r or '[A2试算]' in r for r in remarks)
```
## 关键API
| API | 参数 |
|-----|------|
| `ordersQuery` | warehouseId, saleMode, platformCode, orderStatus |
| `addOrderRemark` | remarkList: [{orderIdKey, remark}] |
| `ordersRemarkQuery` | orderIdKey |
| `getShippingMethod` | warehouseId |
| `orderUpdate` | orderIdKey, shippingMethodId, orderId |
### orderUpdate 的 orderId 问题
- TEMU半托管订单的 `orderId` 字段在API返回中**为空字符串**
- 传外部单号(`4Caohalf-PO-211-xxx`)报 `订单不存在!`code=527
- `orderIdCode` 字段有值但API不接受该参数
- **结论**`orderUpdate` API对TEMU半托管订单不可用,必须走**UI方式**修改物流
- 批量测试:12次API改物流全部报"订单不存在",确认API不可用于TEMU半托管
### update_shipping_ui 修正(2026-05-01
参考 `tongtool-y2-lowest-shipping` skill 的 `get_best_method_code` + `update_shipping_method` 逻辑。
**关键修正点:**
1. **Playwright evaluate传参**:只能传1个参数,必须包装成对象 `{"method": "GOFO"}` — 旧代码传2个参数 `(provider, method)` 永远失败
2. **下拉框dd匹配规则**dd选项只显示物流方式名(如 `EJET-GOFO(美东)`),**不包含物流商名**(没有"湖北众信"前缀),所以只需按末端名匹配,不需要同时匹配物流商
3. **添加 `get_best_method_code()` 标准化**`SwiftX,Standard``SwiftX``GOFO,Standard``GOFO`
4. **保存按钮排除 `#orderRemarkForm`**:避免保存备注而非物流
5. **移除UI函数内的API逻辑**:API尝试已在主循环中先做,UI函数只负责纯UI兜底
```python
def get_best_method_code(method_str):
# USPS→USPS, GOFO→GOFO, SWIFTX→SwiftX, SWIFT→Swift etc.
async def update_shipping_ui(page, best_last_mile):
best_sub_method = get_best_method_code(best_last_mile)
# 检查当前是否已是最优
current_text = await page.evaluate("() => {... find input with '>>' ...}")
if best_sub_method.upper() in current_text.upper(): return True
# 单参数对象传值
result = await page.evaluate("(args) => {...}", {"method": best_sub_method})
# dd只匹配method名,不匹配provider
```
**主循环调用(line ~827):**
```python
sid = find_shipping_method_id(shipping_methods, carrier_keyword, best_last_mile)
if sid:
update_shipping_method_api(oik, sid, order_code) # API优先
else:
ui_ok = await update_shipping_ui(main_page, best_last_mile) # UI兜底
```
### UI物流下拉选项匹配
- 下拉框dd选项只显示物流方式名(如 `EJET-GOFO(美东)`
- **不包含物流商名**(没有"湖北众信"前缀)
- 匹配条件应**只匹配末端派送名**`item.textContent.includes("GOFO")`
- 不需要同时匹配物流商
### carrier/method 完整解析示例
```
TEMU行: raw=['3704583687894247366', 'SwiftX,Standard', '预估$2.42; 1-5 天送达', 'TEMU半托管美东>>SwiftX,Standard...', '奥盛-美东']
→ 跳过TD0(空)、TD1(纯数字)、TD3(含"预估")、TD4(含">>")
→ carrier = TD2 = "SwiftX,Standard"
→ method = "SwiftX,Standard".split(",")[0] = "SwiftX"
→ best_last_mile = "SwiftX"
```