Files
2026-07-10 16:10:13 +08:00

16 KiB
Raw Permalink Blame History

name, description, category
name description category
temu-pipeline TEMU半托管订单全流程流水线 — 从API获取武汉仓库TEMU半托管订单,过滤[H201试算]/[A2试算]已处理订单,逐个试算最低运费,API+UI双策略修改物流,通过API写[H201试算]备注并验证。含EPIPE防护、carrier解析修复、分批处理。 ecommerce

TEMU 半托管订单全流程流水线 [H201试算]

一键完成:API获取武汉仓库半托管订单 → 排序 → 逐个试算最低运费 → API动态匹配物流(失败则UI兜底) → API写备注 → 验证重试。

依赖

pip install playwright ddddocr requests
playwright install chromium

使用

# 推荐版本 — 纯Playwrightiframe穿透稳定
python3 scripts/pipeline_hybrid.py

# 旧版本 — DrissionPageiframe穿透不稳定,编辑按钮成功率<2%
python3 scripts/pipeline_dp.py

关键发现(会话经验总结)

武汉仓库

  • 名称: 武汉仓库
  • warehouseId: 0006000951201403050000004680
  • warehouseType: owner

获取TEMU半托管订单(最关键参数)

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 中,不在 orderIdplatformOrdersQuerysalesRecordNumber 返回纯数字,不带 -PO- 格式。

过滤TEMU订单

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,StandardSwiftX)

搜索结果页"编辑"按钮

通途详情页需要先点击 "仓储"链接(搜索结果页的子tab,非文字"仓储物流"),仓储内容在iframe中加载。需在iframe内查找"编辑"和"邮寄方式解析"。

// 正确方式
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抛异常
});

物流修改判断逻辑(⚠️ 常见错误)

# ❌ 错误:与试算结果的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 → 需要修改
# 策略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%)

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(),无需关闭弹窗。

失败情况:目标物流在试算结果中不存在(渠道未启用、无匹配行)。此时回退到关闭弹窗尝试下拉框(成功率低)。

文件锁机制(防止多实例并发)

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,但会造成混淆。

主函数结构

async def main():
    lock_fd = acquire_lock()
    if lock_fd is None:
        print("已有实例在运行")
        return
    try:
        # ... 主逻辑 ...
    finally:
        release_lock(lock_fd)  # 确保异常时也释放

日志归档(每次运行保存独立日志)

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")

使用

async def main():
    setup_logging()  # 放在最前面
    # ... 后续print都会同时写入文件和stdout ...

查看历史统计

grep '完成!' ~/.hermes/logs/temu-pipeline/*.log

日志位置~/.hermes/logs/temu-pipeline/run_YYYYMMDD_HHMMSS.log

Playwright EPIPE崩溃

长时间运行(15+订单)导致Pipe断开。不要关闭browser,只导航到登录页重新认证:

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不能为空!

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兜底
      • 注意:orderUpdateorderId 参数传外部单号(4Caohalf-PO-211-xxx)但TEMU订单可能报订单不存在!API限制)
      • UI下拉框匹配规则:只匹配末端派送名("GOFO"),不匹配物流商("湖北众信")——选项dd元素不含物流商前缀
    • API写 [H201试算] 备注 → verify_remark 验证 → 重试

物流修改 vs 备注写入的分离

操作 方式 成功条件
改物流 API orderUpdate + UI兜底 API: 需要orderId字段(TEMU订单可能为空);UI: 需要下拉正确弹出
写备注 API addOrderRemark 只需要orderIdKey,总是可用

关键策略:改物流和写备注是两个独立步骤。即使物流修改失败,也必须写备注

去重逻辑

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,StandardSwiftXGOFO,StandardGOFO
  4. 保存按钮排除 #orderRemarkForm:避免保存备注而非物流
  5. 移除UI函数内的API逻辑:API尝试已在主循环中先做,UI函数只负责纯UI兜底
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):

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"