后台没有批量接口,那就让浏览器自己点。
项目背景
定制商品(消费者可以自己填字、选颜色、换图案的 POD 商品)在卖家后台是最贵的一类操作:每个 SPU 都要单独进后台画定制区域——印花框的 X/Y/宽/高、文字区的字数上限、可选颜色栈、对齐方式,一个 SKU 颜色一套,一个 SPU 可能有十几套。后面还要接着补 SKU 规格、尺码表、敏感属性、三边尺寸、重量、申报价,上新前再逐个分组调一遍价。
这些页面全都没有开放批量接口,运营只能一项项点,单个商品几分钟起步,几百个 SPU 就是几天。这个工具把其中重复度最高的四段操作拆出来做成批处理作业:位置和参数从本地配置读,浏览器负责点,人只负责确认结果和处理失败项。
任务表状态机
QThread 后台执行
stdout 重定向到日志窗
CSV / Excel 模板导入导出
SPU 状态定制区域ZT 字体位置价格调整新增 SKU统一生命周期connect → run → close
Semaphore 控制并发
状态回调上报 GUI
供应链 ERP — 定制商品采集箱
卖家中心 — 定制模板 / SKU
上新中心 — 分组调价
同 context — 多 Page 并发
运营自己的浏览器,自己的登录态
四条自动化作业
在供应链 ERP 的采集箱里翻页抓取定制商品编号,和本地 CSV 比对后增量追加。遇到已存在的编号或指定的终止编号就停,重复跑不会写重复行。
按「模板 + SKU 颜色」从 JSON 取出该 SKU 的定制区域,上传对应背景图,再逐个区域设定图片/文字类型、X/Y/宽/高、字数上限、可选颜色栈和对齐方式,单个 SKU 最多 9 个区域。
一行一个 SKU 的 Excel/CSV(编号、规格、尺码表、敏感属性、三边尺寸、重量、申报价共 10 列),同 SPU 的多行聚合成一次后台操作,避免反复进出编辑页。
按关键字在调价抽屉里勾选目标分组,首行填主价、其余行填副价(主价 − 差额)。开填之前先读一遍原申报价,已经低于目标价的直接跳过。
四条作业共用同一套壳:顶部控制台负责启动浏览器和触发任务,下面五个标签页分别管队列状态、定制区域位置、文字定制位置、调价参数和新增 SKU 表格,底部是一条实时日志。运营拿到的是一个能双击打开的应用,不需要装 Python,也不需要碰命令行。
界面
所有坐标和参数都落在本地 JSON 里,改完保存即生效,不需要重新打包。
SPU 队列:总计 / 待处理 / 已完成,右侧是失败记录与重新入队
定制区域:每行一个(模板, SKU 色),弹窗里编辑位置、字数、颜色栈与对齐
文字定制位置:(模板, SKU 色, 印花)三元组对应一组 X/Y/W/H 调价和新增 SKU 都以「参数 + 表格」驱动,执行状态逐行回写,失败项可以单独导出。


最初的版本用 Playwright 自己拉一个干净浏览器,结果每次都卡在登录:验证码、短信、设备风控,跑一轮要人守着。改成 CDP 接管后,脚本连的是运营自己开着的那个 Chrome——登录态、Cookie、插件、扩展全都还在,工具只是多开几个标签页。
self.browser = await self.playwright.chromium.connect_over_cdp("http://127.0.0.1:9222")
self.context = self.browser.contexts[0] # 复用已登录 context,不新建
sem = asyncio.Semaphore(max_concurrent) # 同 context 下多 Page 并发
await asyncio.gather(*[self._process_spu(spu, sem) for spu in spu_list])真正花时间的从来不是「点哪个按钮」,而是页面不按预期响应:React 重渲染把刚拿到的元素判成 detached,fill() 塞进去的值被组件自己重置回旧值,取色器点了「+ 添加」却不弹出来。所以每一层都要能自己兜住。
字段填充 ×5填完 Tab 出焦点,回读控件值确认写进去了;不一致就退避重填,退避时长随次数递增。
单商品 ×3整个商品的流程失败就关掉标签页重开一个从头跑,避免脏状态影响下一次尝试。
取色器 ×3 / ×4「+ 添加」点了不弹就再点;「确定」点了不关就再点。RGB 三个通道逐字符输入,让组件收得到每一次 input 事件。
幂等跳过已配置过定制信息的商品直接跳过;调价前先读原申报价,已低于目标价不再调;队列里成功和已跳过的行不会二次执行。
重试到头仍然失败的商品编号会写进日志文件并回到界面的失败列表,可以一键重新入队或导出,跑一整批不会因为中间几个卡住就全部作废。另一类问题是匹配:后台的规格名全角半角混排、中英文夹空格,直接字符串比对必然漏。统一做 NFKC 归一化 + 去空白 + casefold 之后才比,中文 SKU 匹配不上的问题就消失了。
async def _fill_number_with_retry(self, loc, target: str, label: str):
for retry in range(1, 6):
await loc.click()
await loc.fill(target)
await loc.press("Tab")
try:
await expect(loc).to_have_value(target, timeout=3000) # 回读确认
return
except Exception:
if retry == 5:
raise ValueError(f"[{label}] 填充失败,已达最大重试次数")
await asyncio.sleep(0.5 * retry) # 退避后重填自动化内核是纯 asyncio 的,界面是 Qt 的,两者各有各的事件循环。做法是每个任务起一个 QThread,在线程里新建独立事件循环跑协程;协程里的 print 通过替换 sys.stdout 转成 Qt 信号,逐行推到界面下方的日志窗——命令行时期的输出一行都不用改就有了 GUI。
「强制停止」是从主线程 call_soon_threadsafe 停掉子线程的事件循环。收尾时要注意:Playwright 的协程被取消后偶尔卡在原生调用上,不限时清理会让线程永远不返回,所以退出路径上加了限时兜底。任务表本身是个状态机(待处理 / 处理中 / 成功 / 失败 / 已跳过),停下来之后重新开始,已经成功和已跳过的行会被略过,等于天然的断点续跑。
- ›每任务一个 QThread
- ›线程内独立 asyncio loop
- ›Signal 回主线程更新 UI
- ›call_soon_threadsafe 停循环
- ›sys.stdout 替换为信号流
- ›内核代码零改动
- ›界面实时逐行追加
- ›错误保留原始堆栈
- ›跨平台用户数据目录
- ›首次运行从包内复制初始配置
- ›位置 JSON / 队列 CSV / 失败日志
- ›升级不丢已有配置
工具从命令行脚本起步,但只要交付对象是运营,命令行就是门槛。最终版本用 Nuitka 编译成 macOS 与 Windows 双端可执行程序,配套一键启动调试模式 Chrome 的脚本(自动探测常见安装路径),表格提供可下载的模板,配置文件在首次运行时自动落到用户数据目录。启动时向内部服务校验一次授权,通过后状态栏显示「已授权」,工具本身不保存任何平台账号密码——它用的始终是使用者自己已经登录好的浏览器。
结果
工具累计覆盖约 780 个定制商品的模板配置、SKU 新增与调价,单个商品从「运营点几分钟」变成「排进队列等它跑完」。更重要的是失败不再是黑洞:哪一个编号卡住、卡在哪一步、要不要重来,都在界面上能看见。这个工具后来和 POD 商品自动化运营平台 一起构成了同一条链路——平台负责素材、上架和出图,它负责后台里那些只能手点的部分。