浏览器自动化

确定性优先的浏览器自动化:规则写进代码,LLM 只做兜底

让 Agent 自己点页面听起来很酷,直到它在「批量上架」的按钮上多点了一次。上架自动化和爬虫不一样:爬虫跑错了最多拿到脏数据,上架跑错了是直接把商品挂到店铺上。starmerx_listing 这个项目从头到尾只贯彻一条原则——能被代码表达的规则,绝不交给模型;LLM 只在规则层失败之后才被允许出场。

「失控」是怎么发生的#

先看这个项目要解决的问题:把「产品池 → 加入上架计划 → 图片/价格校验 → 批量上架」这条流程自动化,MVP 范围收窄到圣诞花环单品类。它的反面教材非常典型:

  • 把「判断这张图是不是白底主图」交给视觉模型,模型今天说是、明天说不是;
  • 把「这个价格算出来是赚是亏」交给模型心算,它给你一个看起来很合理的数字;
  • 把「点哪一个按钮」交给模型理解页面,页面改版后它开始点错地方。

这三种失败有一个共同特征:它们都不会报错。程序照常跑完,日志里一片绿,只有店铺后台知道出了事。所以这个项目的对抗手段不是「让模型更聪明」,而是把流程切成两类:规则层(纯代码、可复现、会抛错)和兜底层(模型,只在规则层失败时介入)。

技术栈与四阶段编排#

技术栈就三样:Python 3.13、Playwright 1.62、以及作为兜底的阿里云通义千问 Qwen。Playwright 负责确定性 DOM 操作,Qwen 是预留位——只有在规则层失败时才被调用。

入口是 run.sh,src/main.py 是四阶段编排:

tree
starmerx_listing/
├── run.sh                 # 一键启动脚本
├── config.json            # 全部配置(固定表单值/类目/校验规则/安全开关)
├── src/
│   ├── main.py            # 四阶段编排入口
│   ├── login.py           # 钉钉扫码 + storageState 持久化
│   ├── phase1.py          # 产品池 → 加入上架计划
│   ├── phase2.py          # 图片/价格校验 + 批量上架
│   ├── price_calc.py      # 利润试算引擎(有界二分,防死循环)
│   ├── dom.py             # DOM 操作辅助
│   └── config.py          # 配置/日志
├── scripts/
│   └── probe_product_pool.py  # 独立勘察工具
└── runtime/               # storage_state.json、勘察截图、日志
mermaid 图 1 · 四阶段编排,以及默认干运行的分支
flowchart TD L["run.sh login:钉钉扫码"] --> SS["runtime/storage_state.json<br/>约 2 天后过期"] SS --> S1["① 产品池:按系统类目取候选 SKU"] S1 --> S2["② 加入上架计划<br/>phase1.py"] S2 --> S3["③ 图片 / 价格校验<br/>phase2.py"] S3 --> S4["④ 批量上架<br/>phase2.py"] S4 --> G{"dry-run?"} G -->|"默认:不带参数"| DRY["只输出清单,店铺没有变化"] G -->|"显式关闭干运行"| LIVE["真实写入店铺"]
登录态是这条链上唯一会自己过期的东西(storageState 约 2 天);干运行不是调试开关而是默认值,要真上架必须显式关掉它,再叠加 --max-sku 限量。从“写完代码”到“真实上架”之间因此隔着两次确认。

把配置单独放进 config.json 是个不太起眼但很重要的决定:固定表单值、类目、校验规则、安全开关全在里面,于是「改规则」不需要改代码,也就不会在改规则的时候顺手改坏流程。

配置项值
平台 / 站点 / 店铺 / 品牌Tik Tok / tiktok.com / Garvee Mate / 无品牌
系统类目(产品池)家居厨具 → 节日饰品 → 圣诞花环花带 → 圣诞花带装饰
平台类目(上架表单)Home Supplies → Festive → Wreaths, Garlands & Swags
SPU 图片8 张(含白底主图),SKU 色标一一对应
活动营销费占比50%
个人利润目标4%–6%(通过调「公司利润率」实现)
公司利润率边界0.30–0.60(待确认)
GTIN / 生成方式豁免 / 按 SPU 维度生成多上架计划

一个版本差异值得说明:早期的一份项目宣传页把这条流程写成「NestJS + Playwright 的五步流水线」,而仓库代码与 README 的现状是 Python 3.13 + Playwright 的四阶段编排(src/main.py)。本文以仓库为准——这也是本文所有参数与文件名的来源。

登录态:钉钉扫码 + storageState 的两天寿命#

登录用的是钉钉扫码,登录结果通过 Playwright 的 storageState 持久化到 runtime/storage_state.json。命令行里第一次要显式跑一次:

bash
./run.sh login                # 弹出浏览器,钉钉扫码(首次/过期)

这里有一个必须写进设计里的现实:这份登录态大约 2 天就会过期。它不是「配好就一劳永逸」的那类凭据。过期之后的处理方式决定了这套系统是「能长期跑」还是「跑三天就要人管」:

  • 任务开始前检查 storage_state.json 是否还可用,不可用就立刻停下并提示重扫,而不是带着失效的登录态把后面每一步都跑出奇怪的错误;
  • 把「过期提醒」当成常驻功能——README 里也写了 cron 每日定时 + storageState 过期自动提醒重扫;
  • 不要试图绕过扫码去硬编码凭据,这会把一个「两天一次的人工动作」变成「一个迟早会炸的隐式依赖」。

勘察优先:先确认选择器,再谈执行#

项目里有一条硬性流程:任何新页面,先跑 probe 确认选择器,确认后才进入执行模式。

bash
./run.sh probe-phase1         # 勘察产品池页
./run.sh probe-phase2         # 勘察上架计划页

scripts/probe_product_pool.py 是一个独立的勘察工具,只观察、不修改数据。这个「先勘察」的动作解决的是一个很典型的悖论:写自动化最难的部分不是点击,而是在你还没看清页面结构之前,就得先写选择器。没有勘察步骤,你就只能一边跑真流程一边调选择器——而每一次失败的尝试,都可能已经在页面上留下痕迹。

勘察的产物留在 runtime/ 下(截图与日志),它同时充当了「这个选择器当时长什么样」的证据。页面改版导致自动化失效时,翻一眼旧截图就能确认是页面变了,还是代码写错了。

利润试算:用有界二分防死循环#

整条流程里最需要「确定性」的地方是利润试算。业务约束是这样的:个人利润目标定在 4%–6%,而达成方式是反向调整「公司利润率」——因为个人利润是公司利润率的函数。可调边界暂定在 0.30–0.60。

这就是一个典型的单调函数求根问题:公司利润率越高,个人利润越低(或反之),中间存在一个让个人利润落进 4%–6% 区间的取值。用二分法求解最自然,但二分法有一个必须提前掐死的风险:如果目标区间在当前边界内根本不可达,朴素实现会永远迭代下去。

src/price_calc.py 的约束是:最多 20 次迭代,未命中即报「需人工介入」,绝不无限循环。这条规则的意义超出这一个函数——它代表了一种态度:

失败情形坏的做法这个项目里的做法
目标区间不可达一直试,直到超时20 次迭代后停止,明确报「需人工介入」
公式是占位模型假装结果可信在 README 里写明 K=0.24 是占位模型,需替换
边界值不确定按猜测值上线列为 P1 待确认事项,上线前必须核对

第三条尤其值得学。README 里明明白白写着:如果利润率边界错误,利润试算可能永远无法命中区间而死循环——所以需要人在真实的「利润测算」弹窗里确认可调范围。把「我不知道」写进文档,比让程序假装知道要负责任得多。

默认干运行#

所有阶段默认是干运行:只检查、只输出清单,不真正上架。要真执行,必须显式加参数:

bash
# 跑通单品类(默认干运行:只检查不上架)
./run.sh phase1 --max-sku 1   # 选 1 个 SKU 加入上架计划
./run.sh phase2              # 图片/价格校验(干运行)

# 正式执行(确认无误后)
./run.sh phase1 --no-dry-run --max-sku 5
./run.sh phase2 --no-dry-run

注意 --max-sku 这个参数的存在本身也是一种安全设计:它让「先小批量验证」成为一个不需要额外开发就能做到的动作。默认干运行 + 限量执行,两者叠加之后的实际效果是——从「写完代码」到「真实上架」之间,隔着两次显式的确认。这两次确认谁都不能省,包括自己。

每一步都用 DOM 状态验收#

自动化脚本最常见的坏习惯是「点完就走」:click() 之后不检查任何东西,假定它成功了。这个项目的要求是:每次点击后都用 DOM 状态确认,失败即中止——具体确认的对象是输入框的 value、复选框的 checked 这类状态量,而不是「页面看起来变了」。

这条规则把「静默失败」转成了「显式报错」。在批量操作里,静默失败是最贵的:第 3 个 SKU 没勾上,程序继续往下跑,最后你得到的是 100 个商品里混着几个配置错误的——而这些错误要花比跑一遍自动化多得多的时间去清理。

LLM 被放在哪个位置#

把前面的规则串起来,LLM 的位置就非常清楚了:

环节执行者理由
选择器定位与点击Playwright(确定性 DOM)可复现,失败会抛错
利润试算纯代码 + 有界二分数字必须可复现,不能「感觉差不多」
图片/价格校验规则优先,Qwen 兜底规则能判的就别问模型
登录钉钉扫码 + storageState凭据交给平台,不自己发明
视觉/语义兜底通义千问 Qwen(预留)只在规则层失败时介入
mermaid 图 2 · 规则层在前,LLM 在规则失败之后
flowchart TD W["规则层:Playwright 定位与点击<br/>纯代码 + 有界二分"] --> Q{"这一步规则层通过了吗?"} Q -->|"通过"| OK["继续下一步"] Q -->|"不通过"| ERR["显式报错并中止,不静默跳过"] ERR --> M["兜底层:通义千问 Qwen(预留位)"]
主路径上只有确定性代码:定位、点击、利润试算都可复现、会抛错。模型是“规则失败之后的下一个人”,它的输出不稳定没关系,因为它本来就不在主路径上。

这张表可以概括成一句话:把模型当作「规则失败之后的下一个人」,而不是「第一步就上场的那个人」。它的输出不需要稳定,因为它本来就不是主路径;主路径必须稳定,所以主路径里不能有它。

没有做完的部分,写在 README 里#

待确认事项

这个仓库有一节叫「待确认事项(上线前必须解决)」,里面列了两条:一是公司利润率的可调边界(暂定 0.30–0.60);二是 src/price_calc.py 里的个人利润公式是占位模型(用 K=0.24 做缩放),需要勘察到真实「利润测算」弹窗的逻辑之后替换成精确公式。

把占位模型写在最显眼的位置,是一种工程上的诚实。任何接手这个仓库的人(包括三个月后的自己)都会知道:利润数字目前只能当作流程通路的证据,不能当作决策依据。相比之下,「假装公式是对的,等出问题再说」才是真正的技术债。

部署与服务化#

README 给的部署建议是阿里云轻量 2C2G 的 Ubuntu(香港或新加坡节点,需要先测试 op.starmerx.com 的可达性),用 cron 做每日定时,并配合 storageState 的过期提醒。这个规模的选择很务实:流程本身不重,重的是浏览器——而 Playwright 在 2G 内存上跑单实例是够的,前提是别并发开一堆页面。

需要说明的是,完整的部署文档(docs/DEPLOYMENT.md)在仓库里标注为「待编写」。所以这一节只到「推荐什么机器、怎么定时」为止,再往下写就是编造了。

小结#

这个项目的所有设计选择,都可以用同一把尺子量:这一步出错的时候,我会不会知道?干运行让你知道、DOM 验收让你知道、有界二分让你知道、勘察截图让你知道、把占位模型写进 README 也让你知道。

反过来说,让 LLM 自由发挥之所以在上架场景里危险,不是因为模型不够聪明,而是因为它的失败方式是不报错的失败。确定性的代码不是「低级方案」,恰恰相反:在一个会真金白银出错的场景里,可复现、会抛错、有边界,才是最值钱的三个属性。