自动上架系统:用 Playwright 构建可控发布流程

先说结论:它解决的是重复的劳动
这套工具的目标,不是把鼠标点击录制下来,再批量重复一遍。真正要解决的是一条商品发布链路里的多个一致性问题:
- Excel 里的商品、型号和组号,必须能对应到正确的商品页面;
- 当前 BigSeller 页面、店铺和平台必须是操作者明确打开的那一个;
- 主图、详情图、视频和变种图不能因为页面顺序变化而错配;
- AI 可以帮助识别图片和生成标题,但不能擅自改掉品牌、型号和固定字段;
- “看到成功提示”不等于平台真的接受了发布;
- 批量任务中途暂停、失败或重试后,必须知道已经处理到哪里。
所以我把它做成了一个运行在 Windows 桌面上的本地工具。文章第一次发布于 2026 年 7 月 9 日;当时仓库还处在 v1.0.0 的早期基线。现在本地工作区已经推进到 v4.1.1,主要服务 BigSeller 中的 Shopee 和 TikTok Shop 商品维护与批量发布。本文的代码和流程均来自这套本地项目;代码块已经去掉 API Key、绝对路径、店铺名称和运行数据。

从第一次发布到现在:这不是一次换皮,而是一次工程升级
Git 历史最能说明这套工具发生了什么变化。第一次发布前后的 9b877e13(2026-07-08)仍然是 v1.0.0:已经有 Shopee/TikTok Shop 双 Worker、4 种标题模式、实时监控、历史记录和 7 个 Shopee 模板 + 1 个 TK 模板,但它更像一套“已经能工作的批量上架工具”。
截至 2026 年 9 月 18 日,最新正式提交是 53d29aff(v4.1.0),本地工作区又继续推进到了 v4.1.1。两个月里的主要变化不是继续堆叠按钮,而是把系统从“能跑”推进到“知道什么时候能跑、为什么停、停在哪里、结果是否可信”。
| 时间与提交 | 变化 | 解决的问题 |
|---|---|---|
7 月 19 日:af9d8a70 | 编辑页二次确认、品牌复核,并为两个平台补充测试 | 搜索到商品不代表打开的就是目标商品,列表品牌也不一定可信 |
7 月 22 日:1be3eb28 | 用商品链接和编辑页标题共同校验身份,并兼容旧列表页 | BigSeller 页面路由和打开方式变化后,仍能判断页面是否属于目标商品 |
7 月 24 日:6afae1e6 | 完善 TK 配置和印尼型号表 | TikTok Shop 的表格列、型号和页面流程不能直接照搬 Shopee |
8 月 1 日:1409482c、74efdadc | 加入自定义上架方案和店铺整套配置 | 把一次运行的临时选择,提升为可以保存、复用和按店铺隔离的方案 |
8 月 6—11 日:d4ec311e、767bab7b、52109f6c、8dfd7b70 | 稳定规则标题、取消自动插入连接词、主图先传、细化进度和发布收尾 | 标题字段不再被 AI 随意改写,上传顺序和成功收尾更符合真实页面状态 |
8 月 15 日:bfb9d084 | 加固配置系统和 SKU 工具,增加图片冲突测试 | 运营维护动作开始进入同一套配置和校验边界,而不是各自写脚本 |
9 月 6—12 日:2ceecc97、ae6227cf | 增加库存扫描兜底与报表能力,更新模板和型号配置 | 系统从“发布商品”扩展到发布前后的库存、型号和运营维护 |
9 月 16—18 日:6b7fc1e7、53550478、c41ff776、53d29aff | 分批上架范围、多店铺分开上架、PNG 兼容、图片组严格匹配、编辑页身份严格校验和范围导航 | 批次更大、店铺更多之后,必须把运行范围、图片关系和页面身份锁死 |
这条演进路线可以概括成五个转变:

- 从“搜索到就编辑”变成“列表行、编辑页、品牌三次确认”;
- 从“按文件名和数组顺序上传”变成“按页面真实变种标签做精确映射”;
- 从“一个配置覆盖所有场景”变成“模板、平台、店铺和运行范围分别管理”;
- 从“整批任务一口气跑完”变成“可选组范围、可暂停、可恢复、可重试”;
- 从“控制台显示完成”变成“Worker、SSE、截图、事件历史共同证明结果”。
所以现在的系统和文章刚发布时,差别不只是界面更丰富、模板更多,而是错误模型变了:早期主要关心怎样把动作执行完,现在更关心执行前能否证明输入正确、执行中能否定位状态、执行后能否证明平台确实接受了结果。
系统边界:浏览器自动化,但不是“盲点”
工具通过 Playwright 连接已经登录的 Chromium/Chrome 页面,再操作 BigSeller 当前打开的列表页和编辑页。使用时,运营人员先登录并打开正确的平台列表页,工具负责读取页面、查找商品、进入编辑页和执行配置好的动作。
这个边界是故意保留的:工具不会为了“跑通”而偷偷切换到另一个店铺,也不会假设所有店铺、所有平台都共用同一套页面流程。真实业务里,误操作一个店铺的代价通常比少处理一个商品更高。
整个系统可以抽象成四层:
Excel / 图库 / 视频 / 模板配置 │ ▼ server.js 控制层 Express API · 浏览器上下文 · SSE · 历史记录 ┌──────┴──────┐ ▼ ▼ batch-worker.js tk-worker.js Shopee 流程 TikTok Shop 流程 └──────┬──────┘ ▼ BigSeller 当前页面 │ ▼ 截图 · 事件 · 产品结果 · Tokenserver.js 不把所有业务都塞进一个请求处理器,而是负责控制面板、浏览器连接、配置读写、状态广播和 Worker 生命周期。Shopee 与 TikTok Shop 各自有 Worker、运行状态、暂停文件、SSE 通道和平台选择器;shared.js 只承载两边都能复用的解析、路径、图片和标题规则。
一次任务从哪里开始
一个批次的输入并不只有一个 Excel 文件。系统会同时使用:
| 输入 | 作用 |
|---|---|
| Excel 文件或目录 | 提供商品名、商品 ID、型号、组号等结构化数据 |
| 图片目录 | 按品牌、模板和组号组织主图、详情图、变种图 |
| 视频目录 | 按模板配置选择随机视频或指定视频 |
| 模板配置 | 决定品牌、标题字段、图片序号、视频策略和平台规则 |
| BigSeller 当前页面 | 提供实际商品列表、编辑页 DOM 和最终发布入口 |
Excel 不是简单的“导入后逐行点击”。Worker 会先读取文件,解析出可搜索的组号,再根据模板和品牌建立映射。运行时还会把本次上架范围传入子进程,让控制层可以明确知道这一轮处理的是哪一段数据。
运行范围是输入契约,不是一个按钮
批量任务最容易被低估的参数是“这次到底处理哪些组”。早期的界面更接近“从某个位置开始,再跑多少组”;现在服务端把它规范成 groupStart、groupEnd 和兼容性的 groupCount。其中 groupEnd 是绝对组号,不是“再跑几组”,因此“第 2 组到第 4 组”和“从第 2 组开始跑 3 组”最终会落到同一个明确范围。
服务端在启动 Worker 前会先把结束组物化,再拿当前模板实际读取到的 Excel 组数做边界校验:
function materializeListingScope(scope, totalGroups) { const total = Number(totalGroups) || 0; if (!scope || total <= 0) return scope;
const groupEnd = scope.groupEnd > 0 ? scope.groupEnd : total; return { ...scope, groupEnd, groupCount: groupEnd - scope.groupStart + 1, };}
// 起始组超过表格总数、结束组小于起始组、结束组超出总数,都会拒绝启动const scopeCheck = validateListingScope('shopee', requestedScope);if (!scopeCheck.ok) return res.json({ ok: false, error: scopeCheck.error });这一步的价值在多店铺场景里更加明显。每个店铺的 Excel 组数可能不同,界面会先检查本地工作区是否真的解析出组号,服务端还会再次拒绝超出表格的结束组。前端禁用“下一步”只能改善体验,不能承担安全边界;真正决定任务能否启动的必须是服务端的第二次校验。
server.js 启动 Shopee Worker 的核心逻辑大致如下。这里保留了实际的进程边界,省略了 UI 和错误处理之外的细节:
// server.js:启动一个独立的 Shopee Worker(脱敏节选)const scriptPath = path.join(__dirname, 'batch-worker.js');const proc = spawn('node', [scriptPath], { cwd: __dirname, env: { ...process.env, LISTING_GROUP_START: String(listingScope.groupStart), LISTING_GROUP_COUNT: String(listingScope.groupCount), }, stdio: ['pipe', 'pipe', 'pipe'],});
let buffer = '';proc.stdout.on('data', (chunk) => { buffer += chunk.toString(); const lines = buffer.split('\n'); buffer = lines.pop();
for (const line of lines) { if (!line.trim()) continue; try { handleWorkerMessage(JSON.parse(line.trim())); } catch { addLog('info', line.trim()); } }});Worker 不直接操作控制面板,而是把每一步编码成一行 JSON。这样做有两个好处:浏览器操作和 HTTP 请求不会互相阻塞;同一组事件既可以实时显示,也可以落盘成为历史记录。
// batch-worker.js:Worker 的最小事件出口function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n');}
emit({ type: 'product_start', brand, group: imgGroupId, batchGroupId, batchNum,});Worker 输出的是运行协议,不只是日志
Worker 的标准输出实际上是一条轻量事件协议。product_start 表示进入商品边界,step 表示可以展示给操作者的中间动作,screenshot 记录证据文件,product_done 才代表商品结果已经完成;fatal、paused 和进程退出则分别进入失败、暂停和收尾路径。控制层收到同一条消息后,同时更新内存中的运行状态、SSE 客户端和当前历史产品文件。
这里还有一个很实际的工程细节:子进程的 stdout 不保证一次回调就是一整行 JSON。server.js 会保留上一次回调最后一个不完整的片段,只解析已经出现换行的部分;如果某行不是 JSON,则降级为普通信息日志,而不是让整个控制层崩溃。这使 Worker 偶尔输出第三方库的诊断文字时,批量任务仍能继续运行。
proc.stdout.on('data', (chunk) => { buffer += chunk.toString(); const lines = buffer.split('\n'); buffer = lines.pop(); // 保留可能尚未收完整的 JSON 行
for (const line of lines) { if (!line.trim()) continue; try { handleWorkerMessage(JSON.parse(line.trim())); } catch { addLog('info', line.trim()); } }});最重要的防错:先确认“这是哪个商品”
浏览器自动化最危险的地方,不是点击失败,而是点击成功但点错了对象。BigSeller 的列表可能分页、刷新或重新渲染,页面中的行也可能因为平台升级而变化。因此系统不会只拿一个字符串搜索到行就继续。
Shopee Worker 当前使用 VXE 表格的行选择器定位商品,并要求结果唯一;随后再次读取行标题,确认它与 Excel 中的商品名一致。进入编辑页后,还要检查 URL 和编辑页标题至少有一项能够和目标商品对应:
// batch-worker.js:编辑页身份二次确认(脱敏节选)async function verifyOpenedEditPageIdentity(page, productName, expectedUrl = '') { const actualPath = normalizeEditPagePath(page.url()); if (!/\/(?:web\/)?listing\/shopee\/edit\//i.test(actualPath)) { return { ok: false, reason: '打开的不是 Shopee 编辑页' }; }
const editTitle = await readEditPageProductName(page, 15000); if (!editTitle) { return { ok: false, reason: '编辑页标题为空,无法确认商品身份' }; }
const exactUrl = normalizeEditPagePath(expectedUrl) === actualPath; const exactTitle = normalizeProductText(editTitle) === normalizeProductText(productName);
if (!exactUrl && !exactTitle) { return { ok: false, reason: `编辑页身份不匹配: 列表“${productName}” / 编辑页“${editTitle}”`, }; } return { ok: true, editTitle, matchedBy: exactUrl ? 'url' : 'title' };}品牌也会在编辑页再次判断。列表标题只负责初筛,编辑页无法确认时,Worker 会停止当前商品,而不是“猜一个最接近的品牌”。这也是为什么系统的日志里会明确记录“身份确认”而不是只记录“已点击编辑”。
这套策略也解释了为什么工具不主动替用户切换店铺或跳转到另一个列表页。浏览器里的登录态、当前店铺和当前列表页属于操作者明确选择的运行上下文;工具只在这个上下文内做可验证的动作。自动导航看起来更省一步,但一旦页面路由、店铺筛选或登录态发生变化,脚本可能在错误的业务空间里完成一套完全正常的点击。
图片映射:绝不靠 组号 + 1 猜下一张
批量上架里最容易造成业务事故的是图片错配。一个产品通常同时涉及:
- 商品主图和详情图;
- 与品牌或模板相关的图片序号;
- 页面里的 Color/Model 变种顺序;
- 本地图库中的组号和文件名。
系统在删除旧图之前,会先读取页面真实的变种标签顺序,再和本地预期顺序做精确比较。两次读取不一致、页面缺标签、图片不存在、顺序变化,都会停止,不会退回到“按数组下标上传”的猜测逻辑。
shared.js 中的比较函数要求两边非空、无重复、长度相等,并且每一项按位置相等:
// shared.js:页面变种顺序与本地组号的核心判定(节选)const exact = expected.length > 0 && actual.length > 0 && duplicateExpected.length === 0 && duplicateActual.length === 0 && expected.length === actual.length && expected.every((groupId, index) => groupId === actual[index]);上传变种图时,Worker 会把页面标签映射成文件名,然后逐项检查文件是否存在,并再次比较预检结果与页面结果:
// batch-worker.js:变种图严格对齐(节选)const pageFiles = pageOrder.map((groupId) => resolveListingImageName(baseDir, groupId, mainImageIndex));
if (!pageFiles.every((file) => existsSync(path.join(baseDir, file)))) { throw new Error('Strict variant match failed: image file is missing');}
const orderChanged = pageFiles.length !== variantFiles.length || pageFiles.some((file, index) => file !== variantFiles[index]);
if (orderChanged) { throw new Error('Strict variant order changed');}
variantFiles = pageFiles;这段逻辑看起来保守,实际是在保护库存和商品展示。自动化系统的专业性,很多时候体现在它知道什么时候不应该继续。
为什么变种顺序要读两次
BigSeller 的变种区域会随着滚动、懒加载和 Vue 重渲染发生短暂变化。当前 Worker 在首次读取到非空标签后等待一小段时间,再读取第二次;只有长度和每个位置都相同,才把顺序缓存下来。重试时优先使用已经确认过的缓存,避免重新滚动页面又读到半成品 DOM。
const firstOrder = await readVariantPageOrder(page);await sleep(800);const confirmOrder = await readVariantPageOrder(page);const stable = firstOrder.length === confirmOrder.length && firstOrder.every((groupId, index) => groupId === confirmOrder[index]);
if (!stable) { emit({ type: 'fatal', text: '变种标签两次读取不一致,已停止' }); return null;}而且“第一次校验通过”仍然不够。进入删除旧图之前,Worker 会再次读取两次页面顺序,并和最初的 pageOrder 比较。也就是说,真正会修改商品图片的动作前面有一个独立的最终闸门;页面在中间发生重排时,任务宁可停在删除之前,也不会拿旧的读取结果继续执行。

标题生成:AI 只负责它擅长的那一小部分
标题有四种运行模式:固定标题、完整 AI 标题、固定字段规则随机、AI 识别图案词后再由本地规则拼接。这里没有把整条标题完全交给模型,因为品牌、型号、功能词和字符上限都属于确定性约束。
例如 ai_image_shuffle 模式只让 AI 从图片中提取少量图案词,品牌、前缀、型号和功能字段仍由本地配置控制;ai_fixed_shuffle 甚至完全不调用 AI。shared.js 中的规则构造器会把固定字段放在配置指定的位置,模型不能重复或挪动这些字段。
// batch-worker.js:AI 图析 + 本地规则组装const imgWords = await generateTitlePatternOnly( path.join(imageDir, aiImage), brand, pageTitle,);
parts.aiWords = imgWords;const title = buildRuleShuffleTitle(parts, { maxChars: TITLE_MAX });
if (!title || title.length > TITLE_MAX) { emit({ type: 'error', text: '规则标题超过平台字符限制' }); return null;}完整 AI 标题模式也会检查语言、清理换行和引号、限制字符数;写入页面后再读取输入框验证,连续失败就停止该商品。模型请求失败不会被伪装成“发布成功”,Token 使用量也会作为运行事件记录。

一个商品的执行顺序
当前 Shopee 流程不是简单的“打开页面 → 上传 → 点击发布”,而是有明确的破坏性动作边界:
- 连接现有 CDP 浏览器,确认 BigSeller Shopee 列表页存在;
- 读取模板和 Excel,确定本次组号范围;
- 搜索商品并确认列表行唯一;
- 打开编辑页,检查 URL、商品标题和品牌;
- 读取页面变种顺序,构建本地图片映射;
- 预检查主图、详情图、视频和变种图是否齐全;
- 生成标题、填写标题并回读验证;
- 在删除旧图前再次读取两次变种顺序;
- 删除旧图,确认删除完成后再分批上传新图;
- 按模板处理视频和变种图,缺槽位时重试;
- 发布前截图;
- 点击 BigSeller 的“保存&发布”,等待平台最终确认;
- 只有商品结果确认后,才写入
product_done和成功历史。
其中第 8 步很关键:即使前面已经做过一次变种检查,在真正删除旧图片这种不可逆程度更高的动作前仍然要复核。页面可能在滚动、异步加载或重渲染后发生变化,前一次读数不能自动代表当前状态。
从流程设计上看,一个商品实际上被拆成三个阶段:
- Prepare:读取 Excel、图库和页面,生成标题与图片计划,但不修改商品;
- Mutate:通过最终闸门后删除旧素材、上传新素材、填写视频和变种图;
- Commit:拿到平台最终提交确认后,写入
product_done、成功计数和历史结果。
这不是数据库事务,因为 BigSeller 页面本身不能回滚;但它把最危险的动作尽量推迟到所有可验证条件都满足之后,并且把“提交成功”从按钮点击中分离出来。对浏览器自动化来说,这种分阶段比单纯增加等待时间更可靠。
发布成功不是一个绿色 Toast
BigSeller 的 Shopee 发布按钮是下拉按钮,按钮和下拉选项可能使用同一个文案。Worker 需要先展开下拉菜单,再定位真正的选项。点击以后,普通 Ant Design 成功 Toast 只能作为过程信号,不能直接标记商品成功。
当前代码等待包含“操作成功”和“产品已提交 Shopee”的最终弹窗:
// batch-worker.js:最终成功判定(脱敏节选)const modalText = await modal.textContent({ timeout: 2000 }).catch(() => '');
if (modalText.includes('操作成功') && modalText.includes('产品已提交Shopee')) { await closePublishedShopeeEditPage(page, modal); return true;}
// 普通成功提示只记录,不完成商品if (await page.locator('.ant-message-success').count().catch(() => 0) > 0) { emit({ type: 'step', level: 'info', text: '已收到成功提示,继续等待最终提交确认', });}如果按钮消失但最终弹窗没有出现,系统也不会把它当作成功。它会进入重试或失败路径,避免历史记录里出现“看起来成功、实际上没有提交”的假结果。
因此历史记录里的成功数不是“点击了多少次发布按钮”,而是满足一组条件后的结果:目标编辑页身份已经确认、图片与变种映射通过、标题回读通过、必要素材已验证、最终提交弹窗出现,并且 Worker 能完成收尾。任何一项缺失,都不会被包装成成功率。
暂停、恢复和失败边界
批量任务不应该只能“开始”或“杀进程”。Shopee Worker 使用暂停标记和恢复状态文件记录组号、产品索引、批次数量和当前组索引:
// batch-worker.js:暂停时保存可恢复位置function saveResumeState(groupNum, productIndex, totalBatches, totalProducts, groupIdx) { writeFileSync( RESUME_STATE, JSON.stringify({ groupNum, productIndex, totalBatches, totalProducts, groupIdx }), 'utf-8', );}
if (existsSync(PAUSE_FLAG)) { saveResumeState(parseGroupId(keyword).num, productIndex, totalBatches, totalProducts, groupIdx); emit({ type: 'paused', group: keyword, productIndex, totalInBatch });
while (!existsSync(RESUME_FLAG)) await sleep(1000);}暂停并不是在任意一行代码上硬切断。控制层先写入暂停请求,Worker 在产品边界检查标记,保存位置后等待恢复。对于身份不明、图片缺失、变种不稳定、标题回读失败或发布最终确认缺失等情况,系统选择安全停止当前商品,而不是继续扩大影响范围。
停止和暂停也不是同一件事。暂停是协作状态:当前商品完成到安全边界后保存恢复位置,等待 resume.flag;停止则清理控制文件、结束当前进程,并将队列标记为停止。Shopee 与 TikTok Shop 使用各自的暂停/恢复文件和运行状态,因此停止一个平台不会误伤另一个平台的 Worker。
实时状态与历史记录是两条链
实时控制台使用 SSE。Shopee 和 TikTok Shop 分别连接 /events 与 /events-tk,服务端会发送初始状态、后续事件和心跳;浏览器断线时,服务端清理客户端,避免连接数组无限增长。
// server.js:SSE 的公共连接处理(节选)res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no',});
res.write(`data: ${JSON.stringify(initialEvent)}\n\n`);const heartbeat = setInterval(() => { res.write(`: heartbeat ${Date.now()}\n\n`);}, SSE_HEARTBEAT_MS);SSE 只是“现在发生了什么”,不是唯一事实来源。每次运行还会写入本地历史目录:
上架历史/└── YYYY-MM-DD/ └── shopee-<run-id>/ ├── run.json # 平台、模板、店铺快照、统计、Token、最终状态 ├── index.json # 历史列表用的轻量摘要 ├── events.ndjson # 按时间追加的批次/产品/错误事件 ├── products/ # 每个商品的计划、步骤、结果和失败原因 └── screenshots/ # 关键页面截图引用run.json 会保存本次配置快照、平台和模板、处理数量、成功/失败数量、截图数量与 Token 使用量;events.ndjson 则把 Worker 事件按时间追加。这样,实时页面即使被关闭,也能在历史页重新回答:哪个批次失败、失败发生在哪一步、标题和图片计划是什么、最终是否提交。
历史页也没有每次打开都把所有产品和事件一次性读进概览。服务端把运行级概览和单次详情分开:概览只读取日期、平台、状态、成功失败计数和最近产品;用户点进某次运行后,才加载产品 JSON、事件尾部和截图预览。对于长期积累的运行记录,这个边界能避免“查看今天的统计”被历史详情拖慢。

Shopee 与 TikTok Shop:共用底座,不共用假设
两个平台共用以下基础能力:
- Express 服务和嵌入式/调试浏览器连接;
- 模板、品牌、Excel、图库和视频配置;
- SSE 状态、截图和历史记录;
- AI 标题与 Token 统计;
- 暂停、恢复、停止和错误事件。
但它们不会共用平台专属动作。Shopee 使用 batch-worker.js,关注商品列表、编辑页、“保存&发布”、Shopee 提交确认和可选预售;TikTok Shop 使用 tk-worker.js,采用自己的列表入口、Excel 起始行、编辑按钮、更新按钮和成功判断。一个平台的选择器、发布按钮或预售逻辑不能直接复制到另一个平台。
这种隔离比抽象出一个“万能 Worker”更可靠。平台差异被封装在各自 Worker 里,公共部分保持纯函数和数据规则,后续增加平台时可以复用底座,但不必把所有条件分支塞进同一个巨型流程。
当前版本还保留原来的单店铺入口,同时增加了“多店铺分开上架”。后者不是把多个店铺的配置临时拼在一起,而是为每个店铺绑定独立 Excel、图库和整套模板配置,再逐店打开设置核对,全部确认后才进入串行队列。截图中的 BigSeller 店铺列表尚未打开时,系统会明确显示同步失败并禁用下一步;这正是它与早期“默认当前页面继续执行”思路的区别:店铺范围必须被检测、确认,不能靠猜。
多店铺最容易出现的隐性事故不是“选错店铺”,而是店铺专属路径污染全局配置。当前实现会先把店铺工作区合并到内存中的运行配置,Worker 通过 LISTING_CONFIG_PATH 读取这份隔离配置;单店铺使用的 config.json 不会被临时覆盖。服务端还会修复历史版本遗留的路径泄漏,把单店铺全局路径恢复到默认工作区。
function applyMultiStoreWorkspace(config, workspaceDir) { const workspace = getMultiStoreWorkspaceSummary( normalizeMultiStoreWorkspace(workspaceDir), ); const next = cloneConfigValue(config);
for (const template of Object.values(next.templates || {})) { template.excelPath = workspace.excelDir; template.imageBaseDir = workspace.imageDir; } next.paths.tempImages = workspace.imageDir; next.singleMainImageDir = path.join(workspace.imageDir, '单主图库'); return { config: next, workspace };}这也是“多店铺分开上架”与“把多个店铺临时拼到一起”的本质区别:队列可以串行执行多个工作区,但每个 Worker 拿到的是自己的配置快照;单店铺原有配置和运行状态仍然是独立的。


周边工具为什么也要放进同一套系统
真正使用时,批量上架并不是唯一的高频动作。当前控制台还包含表格批量上传、批量添加型号、已知 Model 修复、SKU 修复、描述修改、折扣处理、素材中心清理、视频中心、截图和历史查看等工具。
它们共用同一个浏览器上下文和配置边界,但会检查与上架 Worker 的冲突状态。例如上架运行中,另一个会修改商品数据的工具不能直接抢占同一页面。这样做不是为了让功能看起来更多,而是为了避免两个自动化任务同时改同一个 BigSeller 页面。

平台差异不是配置项,而是两套流程
两个平台共用输入、浏览器连接、日志和历史底座,但关键页面动作保持独立:
| 维度 | Shopee | TikTok Shop |
|---|---|---|
| Worker | batch-worker.js | tk-worker.js |
| 列表入口 | /web/listing/shopee/active/index.htm,兼容旧列表 URL | /listing/tiktok/index |
| Excel 读取 | 商品 ID 第 1 列、Model 第 8 列,通常从第 2 行开始 | 商品 ID 第 14 列、Model 第 17 列,通常从第 5 行开始 |
| 编辑入口 | a[title="编辑"] | a.addEditProduct[title="编辑"] |
| 发布动作 | 展开“保存&发布”下拉并等待 Shopee 提交确认 | 点击“更 新”并判断更新成功 |
| 平台专属逻辑 | 预售、Shopee 提交弹窗、自动关闭编辑页 | TK 变种入口、更新按钮和独立成功判断 |
| 运行控制 | pause.flag / resume.flag | tk-pause.flag / tk-resume.flag |
这张表看起来只是选择器差异,实际代表的是业务语义差异:同一个“发布”在两个平台上不是同一个动作,同一个“成功”也不能共用判断。共享层适合放纯函数、路径解析和组号比较;平台页面动作则留在对应 Worker 中,避免抽象出一个包含大量平台分支的“万能流程”。
测试如何守住这些边界
项目里已经有一组针对高风险路径的 Node 测试,不依赖真实店铺登录即可验证关键规则:
| 测试 | 它防止什么回归 |
|---|---|
test-listing-scope.cjs | 结束组的绝对编号、超出 Excel 总组数、起止倒置和多店铺界面字段错误 |
test-strict-image-group-matching.cjs | 组号错位、重复、顺序变化、缺图,以及删除旧图前没有最终复核 |
test-shopee-publish-confirmation.cjs | 早期成功 Toast 被误判为最终发布成功、发布按钮缺失仍然记成功 |
test-multi-store-config-isolation.cjs | 店铺专属 Excel/图库路径泄漏回单店铺配置,或 Worker 没有读取隔离配置 |
test-listing-state-sync.cjs | 状态事件、暂停/停止和页面状态同步后出现旧状态覆盖新状态 |
这些测试没有试图替代真实浏览器端到端测试。它们的作用是锁住纯规则和关键代码结构,让平台 DOM 变化时,问题尽量暴露在启动前或测试阶段,而不是等到错误商品已经被修改之后才发现。
当前实现的限制
这套系统已经足够支撑日常批量工作,但它并不是一个脱离平台的通用 SaaS,也有明确限制:
- 它依赖 BigSeller 页面 DOM,平台改版后需要重新核对选择器和成功判定;
- 它依赖本地 Windows 文件结构,换电脑时需要迁移配置和素材路径,不能直接复制运行时目录替代配置;
- AI 标题依赖外部模型服务,网络、额度和模型输出都会影响任务,需要保留失败路径;
- 它更适合规则明确、图片和型号组织稳定的商品线,不适合完全没有结构的任意商品;
- 所谓“成功率”必须来自历史记录和平台最终确认,不能只用页面按钮点击次数估算。
这些限制反而决定了系统的工程方向:继续补充平台适配、测试和可观测性,而不是把“全自动”写成不需要人工确认的宣传口号。
最后:自动化的价值是把判断留给人
我现在更愿意把这套工具称为“有明确停止条件的批量发布流水线”。它确实替人完成了大量重复操作,但它没有试图替人承担所有判断:店铺是否正确、素材是否对应、页面身份是否一致、发布是否真的成功,这些地方都必须留下证据。
从代码结构看,系统最值得保留的部分不是某个漂亮的控制台,而是几条朴素的规则:
- 不确定商品身份,就停止;
- 不确定图片映射,就停止;
- 不确定最终提交结果,就不要记成功;
- AI 只处理开放性内容,固定业务字段由规则控制;
- 实时状态用于操作,历史事件用于复盘。
当商品数量、平台和店铺继续增加时,速度只是第一层收益。更重要的是,流程开始拥有统一的输入、可验证的中间状态、可恢复的执行位置和可追溯的结果。这才是我认为自动上架系统真正值得记录的地方。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!
























































