← 所有文章

给运营支持团队做提效工具,做着做着,这个团队没了

本文目录

前言

  前段时间做了一个叫「天工」的桌面自动化工具,主要用来搞定美团开店宝里的好评批量回复和报表自动下载。这是我第一次上手 Electron 和 Puppeteer,整个开发过程基本是靠 Cursor + Claude 4 结对写出来的。

  借着 AI Coding,用自然语言把需求理顺、让模型给出骨架再一点点调细节,很快就能把一个以前没碰过的技术栈跑通。最爽的是反馈极其直观:在可见模式下,看着 Electron 窗口在眼前自动切换平台、选日期、勾指标、一路把报表落盘,那些原本需要运营同学每天机械重复几个小时的操作,程序自己几十秒就跑完了。第一次看着它跑通的时候,确实挺震撼的。

天工的设计与核心架构

  界面结构其实很直接:左边内嵌美团开店宝,右边是天工自己的操作面板,负责账号切换、话术管理和下载任务。

天工界面:左侧嵌入开店宝,右侧管理账号和操作功能

  技术栈是 Electron + React + TypeScript + MobX + Puppeteer。React 负责右侧的面板与交互,Electron 提供桌面窗口、会话隔离和本地能力,Puppeteer 则专门负责去操控开店宝的后台页面。

flowchart LR
    E["Electron 桌面应用"] --> C["Chromium
渲染 Web 界面与开店宝"] E --> N["Node.js
本地文件读写与 npm 生态"] E --> A["Native API
系统窗口、弹窗与存储"]

痛点一:批量好评回复

  运营支持同学手里通常管着几十上百个商家账号,也有提前整理好的分品类回复话术。以前的操作是人工登录每一个账号,找到未回复的评价,逐条复制粘贴话术再点击提交,纯粹的体力活。

  天工的第一期目标就是把这个过程自动化:

  在右侧面板勾选话术后点击「开始回复」,左侧开店宝里的评价回复框就会自动展开并轮换填入话术。这一版把最后的「发布」按钮留给了人工,话术填好后,还需要自己点击发布。

利用 Partition 搞定多账号会话隔离

  在普通浏览器里访问美团后台,同一个域名下 Cookie 是全局共享的。但在天工里,需要在同一个窗口里频繁切换几十个开店宝账号,并且点击右侧账号后,左侧必须瞬间切到对应账号的登录态。

  天工早期使用 Electron 的 <webview> 标签来承载开店宝页面,最核心的技巧只有这一行属性配置:

1
2
3
4
5
<webview
key={currentAccount.id}
src="https://ecom.meituan.com/meishi/"
partition={`persist:${currentAccount.id}`}
/>

  通过给每个账号分配专属的 partition,直接复用了 Chromium 底层的会话隔离机制:

flowchart TB
    S["选择账号"] --> A["账号 A"]
    S --> B["账号 B"]
    A --> PA["persist:account-a
A 的专属 Cookie 与 LocalStorage"] B --> PB["persist:account-b
B 的专属 Cookie 与 LocalStorage"] PA --> VA["webview (账号 A 登录态)"] PB --> VB["webview (账号 B 登录态)"]

  带上 persist: 前缀后,Session 数据会被持久化写入本地磁盘。只要登录态还有效,应用重启后就能接着用,不必重新扫码。

  实际切换非常丝滑。选中账号 A,左侧展示 A 门店的后台:

选中账号 A 后,webview 使用对应的开店宝登录态

  切换到账号 B,左侧无缝切换到 B 门店的会话:

切换到账号 B,页面里的门店和经营数据随账号改变

  第一次发现仅靠一个 partition 就能优雅解掉多账号共存和快速切换的问题时,着实被 Chromium 原生能力的强大惊艳到了。

状态持久化:装饰器 + MobX + electron-store

  除了开店宝网页的 Cookie,天工自身也有不少状态需要落盘:添加了哪些账号、当前选中的是谁、导入了哪些分类话术。

  如果每次修改状态都手动调一次写盘函数,代码会变得极易漏写。这里配合 reflect-metadata、装饰器、MobX 和 electron-store,实现了一套声明式的持久化逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export function persist(target: object, key: string) {
Reflect.defineMetadata('persist', true, target, key)
}

class Account extends PersistStore {
constructor() {
super()
makeObservable(this)
}

@persist
@observable
accountList: AccountWithImportScriptData[] = []

@persist
@observable
currentAccountId: string | null = null
}

  基类 PersistStore 通过反射收集所有打了 @persist 标记的字段:

  1. 应用初始化时,通过 IPC 从主进程的 electron-store 读取数据,回填到 MobX store 中。
  2. 通过 MobX 的 reaction 监听对应属性的变化,并在值发生改变时自动写回 electron-store。
sequenceDiagram
    participant S as Account / PersistStore
    participant R as MobX reaction
    participant P as preload / IPC
    participant D as 主进程 electron-store
    Note over S: 创建 store,完成初始化
    S->>P: 读取持久化字段
    P->>D: get(key)
    S->>R: 针对 @persist 字段注册 reaction
    D-->>P: 返回本地保存的值
    P-->>S: 异步回填属性
    Note over S,R: 业务层修改 accountList / currentAccountId
    S->>R: 属性值发生改变
    R->>P: 节流防抖后 set(key, value)
    P->>D: 写入本地磁盘

  核心的监听写回逻辑非常洗练:

1
2
3
4
5
6
7
reaction(
() => this[key],
(value) => {
this.persistentStore.set(key, cloneDeep(value))
},
{ delay: 300 }
)

  这样业务代码只需要专注修改 MobX 状态,数据的存取和磁盘同步完全自动化了。

查看持久化时序设计原图
Account、PersistStore、MobX 和 electron-store 时序图

从 Excel 导入话术与 IPC 进程通信

  运营同学整理好的话术是以 Excel 表格提供的。点击「导入话术」需要调起系统的文件选择框并读取本地文件内容。

  天工把文件选择和 Excel 解析放在 Main 进程里,React 页面通过 preload 暴露的接口发起请求,再经 IPC(Inter-Process Communication)拿回结果。开启 contextIsolation 后,preload 和页面处于不同的 JavaScript 环境,需要通过 contextBridge 把接口暴露给页面:

sequenceDiagram
    participant UI as React 页面
    participant P as preload
    participant M as Electron 主进程
    participant F as 系统对话框 / Excel
    UI->>P: 调用 window.tiangong.importScripts()
    P->>M: ipcRenderer.invoke('excel-import')
    M->>F: dialog.showOpenDialog()
    F-->>M: 返回选中的本地文件路径
    M->>M: xlsx.readFile() 解析每个 Sheet
    M-->>P: 返回结构化的话术数据
    P-->>UI: Promise resolve 数据
    UI->>UI: 写入 MobX store 并持久化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// preload.ts
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('tiangong', {
importScripts: () => ipcRenderer.invoke('excel-import')
})

// renderer.tsx
const result = await window.tiangong.importScripts()
if (result.success) {
scriptStore.updateGeneralTemplate(result.data)
} else {
message.info(result.error)
}

  主进程中用 xlsx 解析 Excel 时,直接按 Sheet 页提取每一行的第一列:

1
2
3
4
5
6
7
8
9
10
11
12
const worksheet = workbook.Sheets[sheetName]
const rows = xlsx.utils.sheet_to_json(worksheet, {
header: 1,
blankrows: false
})

const scripts = rows.map((row, index) => ({
key: `${sheetName}-${index + 1}`,
index: index + 1,
content: Array.isArray(row) && row.length > 0 ? String(row[0]) : '',
sheetName
}))

  理清这个流程后,Electron 里的几类 IPC 模式也就非常清晰了:

通信方向 核心 API 典型业务场景
Renderer → Main(单向) ipcRenderer.send → ipcMain.on 页面通知主进程执行某项无须等待结果的动作
Renderer → Main(双向) ipcRenderer.invoke → ipcMain.handle 导入文件、读取本地配置并等待结果返回
Main → Renderer(推送) webContents.send → ipcRenderer.on 主进程向前端推送自动化执行进度、下载状态
Renderer ↔ Renderer 主进程中转 或 MessagePort 多页面或窗口间直接交换数据

Puppeteer 页面定位与真实的 DOM 事件模拟

  准备好账号和话术后,就是让 Puppeteer 接管页面进行自动化填写。这里使用 puppeteer-in-electron 连接现有的 Chromium 实例。

  但这里有一个实际问题:React 侧只知道 webview 的 webContentsId,而 Puppeteer 操作的是 Page 实例,如何把它们准确对应上?

  天工早期采用了一个非常朴素且管用的方案:往目标 WebContents 里动态注入一个唯一 GUID,然后再遍历 Puppeteer 识别的所有 Page 找出包含该 GUID 的目标:

flowchart TB
    I["webview 的 webContentsId"] --> W["获取 Electron webContents 实例"]
    W --> G["向页面注入唯一 GUID 标记"]
    G --> T["枚举 Puppeteer 的 targets"]
    T --> P["匹配到 GUID 相同的 Page 实例"]
    P --> F["定位到美团评价的 iframe"]
    F --> R["查找未回复评价的回复框并填入话术"]

  找到开店宝评价 iframe 后,遍历未回复列表并填入话术。在给输入框赋值时遇到了一个经典的前端坑:直接修改 textarea.value,美团页面的 React/Vue 内部状态并不会感知到变化,一旦提交内容依然是空的。

  最终通过派发完整事件链并模拟按键退格搞定:

1
2
3
4
5
6
7
8
9
await textarea.evaluate((el, value) => {
el.value = value
el.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }))
el.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }))
}, content)

// 真实模拟一次按键触发第三方页面的状态同步
await textarea.type(' ')
await textarea.press('Backspace')

  做过网页逆向或自动化的前端应该对这个 hack 会心一笑:很多时候仅仅修改 DOM 属性是不够的,必须真正“骗过”上层框架的合成事件体系。

痛点二:批量报表自动化下载

  比起好评回复,运营支持团队更繁重的一项常规任务是下载门店经营报表并上传到内部系统。

  当时每位同学手里平均管着 120 多家门店,每周一和周二要逐个下载「经营参谋」和「推广通」报表,周四和周五要下载「门店评价」数据。下载时需要人工切进每个门店后台,选时间区间、手动勾选所有指标、提交生成任务,等报表生成后再去下载列表挨个点击下载。同样的操作,每周要机械重复成百上千次。

  天工的第二阶段就是将这部分实现全自动的批量调度与下载,覆盖经营参谋、门店评价、推广通三类数据。据当时统计测算,每人每周能直接省下 5~6 个小时的机械劳动。

无界面后台窗口与会话复用

  批量下载如果还在前端弹出一个个 webview 乱晃,体验会很糟糕。这里的做法是在主进程中按需创建 show: false 的隐藏 BrowserWindow,让 Puppeteer 在后台操作,同时复用前台账号的持久化 partition:

1
2
3
4
5
6
7
8
const downloadWindow = new BrowserWindow({
show: false,
webPreferences: {
partition: `persist:${accountId}`,
nodeIntegration: false,
contextIsolation: true
}
})
flowchart TB
    A["账号 A 的持久化会话
persist:account-a"] V["前台可见 webview
人工登录与日常查看"] --> A H["后台隐藏 BrowserWindow
静默执行自动化任务"] --> A P["Puppeteer
连接 Electron 驱动页面"] --> H

  前台和后台复用同一份登录态,自动化操作放在隐藏窗口里执行,结束后再销毁窗口,避免用完的页面一直占着资源。

经营参谋下载流程与下载拦截

  以最繁琐的经营参谋为例,它需要分别切换美团和点评两个子平台:

flowchart TB
    A["打开后台经营参谋页"] --> B["等待表单渲染完成"]
    B --> C["切换美团 / 点评平台"]
    C --> D["选定日期区间并全选指标"]
    D --> E["点击「立即下载」提交生成任务"]
    E --> F["确认弹窗并进入下载列表"]
    F --> G["等待生成完毕,绑定下载上下文"]
    G --> H["触发最终下载按钮"]
    H --> I["主进程拦截下载流保存至本地"]

  这里关键在于:点击“立即下载”只是触发平台端生成异步报表,必须等列表渲染出对应记录后二次点击,才能拿到真实文件。

  整个任务从 React 面板发起,主进程依次调度各个账号串行处理:

sequenceDiagram
    participant U as React / useDownloadManager
    participant S as downloadTaskStore
    participant M as 主进程下载服务
    participant B as 开店宝后台页面
    U->>S: 初始化主任务与账号子任务
    S-->>U: 返回任务 ID
    loop 依次轮询选中账号
        U->>S: 标记该账号为运行中
        U->>M: 经 IPC 传入账号、日期、任务 ID
        M->>B: 启动后台窗口执行 Puppeteer 下载策略
        Note over M,B: 切换平台 -> 勾选指标 -> 生成报表 -> 触发文件下载
        M-->>U: 返回该账号处理结果
        U->>S: 更新账号任务状态
    end
    U->>M: 调用系统桌面通知

上下文绑定的精准文件拦截

  在批量任务下,可能会有多个平台、多个日期的报表同时触发下载。Chromium 捕获到的只是一个临时 URL,如何知道这个文件属于哪个主任务、哪个账号、哪个平台?

  解决方案是在触发真实的下载动作之前,先在主进程全局注册一个下载上下文(Download Context),downloadInterceptor 捕获到流之后,直接提取上下文元数据来命名并归档文件:

sequenceDiagram
    participant P as 下载策略
    participant D as downloadInterceptor
    participant F as 本地文件系统
    participant U as React 任务界面
    P->>D: 预先设置上下文 (主任务ID、账号、平台、日期)
    P->>P: 点击列表里的下载按钮
    D->>D: 捕获下载请求,读取上下文
    D->>U: 通知创建 FileTask 进度条
    D->>F: 流式写入本地指定目录
    F-->>D: 文件落盘成功
    D->>U: 更新该文件的下载状态
查看经营参谋全链路时序原图
经营参谋下载时序图

任务调度与稳定性保障

  当从单账号操作演进到几十个账号的批量调度时,工程重心就转移到了状态可观测性与异常排查上。

三级任务分层与状态汇总

  为了让前端能清晰展示全局和局部的下载进度,把任务拆分为三级结构:主任务(MainTask) -> 账号任务(AccountTask) -> 文件任务(FileTask)。

flowchart TB
    M["主任务
批量下载经营参谋报表"] M --> A["账号 A 任务"] M --> B["账号 B 任务"] A --> A1["美团报表文件"] A --> A2["点评报表文件"] B --> B1["美团报表文件"] B --> B2["点评报表文件"]

  账号状态不仅有简单的“成功”或“失败”,还支持“部分成功”(例如美团报表处理成功,但点评报表因后台超时失败)。不过,文件下载状态和账号执行结果走的是两条更新路径:文件下载结束后,更新对应文件的状态和账号下的文件统计;账号本身的状态,则由 React 侧根据主进程返回的执行结果判断,再汇总到主任务,驱动面板渲染。

flowchart TB
    F["文件写入成功"] --> FS["更新 FileTask"]
    FS --> FC["累加账号文件完成数"]
    R["主进程返回账号执行结果"] --> AS["更新 AccountTask (成功/部分成功/失败)"]
    AS --> MS["聚合 MainTask 状态"]
    FC --> UI["React / MobX 驱动界面渲染"]
    MS --> UI

基于 AsyncLocalStorage 的独立任务日志

  一旦线上报错,如果所有日志混杂在一个全局文件里,排查起来会极为痛苦。天工为每一次主任务单独分配一个独立的日志文件:

1
2
3
4
5
6
logs/
├── global.log
└── task-logs/
├── main-task-a.log
├── main-task-b.log
└── orphaned-task-activities.log

  但在复杂的异步链路中,层层传递 taskId 非常冗余。这里用到了 Node.js 的 AsyncLocalStorage 来隐式绑定异步上下文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const taskContext = new AsyncLocalStorage<string>()
const taskLogger = log.create({ logId: 'task' })

taskLogger.transports.file.resolvePath = () => {
const taskId = taskContext.getStore()
return path.join(
app.getPath('logs'),
'task-logs',
`${taskId || 'orphaned-task-activities'}.log`
)
}

await taskContext.run(mainTaskId, async () => {
taskLogger.info('开始处理账号', accountId)
await executeDownload(options)
})

  任务结束后,日志文件会自动上传至 OSS 并上报埋点,直接在 Metabase 里按任务 ID 检索定位,省去了找用户远程要日志的沟通成本。

Metabase 中的任务日志查询:创建时间、用户、版本、操作名称和日志下载地址

失败自动截屏:有图有真相

  第三方后台由于样式改版、接口报错或突发验证码,随时可能导致 Puppeteer 找不到选择器。光看报错堆栈很难知道页面究竟卡在什么状态。

  因此在错误捕获分支中增加了自动截屏兜底:

flowchart TB
    E["自动化流程抛错"] --> P["尝试 Puppeteer 截图"]
    P -->|成功| S["写入本地临时图片"]
    P -->|失败| C["降级为 Electron capturePage 原生截图"]
    C -->|成功| S
    S --> U["异步上传至 OSS"]
    U -->|成功| T["关联 TaskId 并上报 URL"]
    T --> M["Metabase 中结合日志秒级定责"]

  日志告诉你代码死在了哪一步,截屏告诉你当时现场是什么样子。 这两套机制上线后,排查线上问题的效率提升了不止一个数量级。

账号会话无缝迁移

  另一个很受运营好评的功能是账号信息的“一键搬家”。

  由于所有登录态都在各自独立命名的 Partitions 目录下,既然已经实现了隔离,那直接把这个目录打包迁移到另一台电脑上,是不是就能免扫码继承登录态?

天工账号管理区的“输出账号信息文件夹到桌面”入口

  天工在主进程中利用 app.getPath('userData') 获取路径,通过 Node.js 剔除无关的 Cache、Code Cache 目录后打包导出至桌面。再配一个简单的 CLI 工具在同事电脑上一键导入:

flowchart TB
    A["同事 A 电脑
已登录的账号会话"] --> E["导出 Partitions 目录
过滤无用 Cache"] E --> D["桌面的账号数据文件夹"] D --> I["同事 B 执行导入 CLI"] I --> P["写入 B 电脑的 Partitions 目录"] P --> S["同步 electron-store 账号列表"] S --> B["打开天工,确认导入账号的登录态"]
1
2
3
4
5
# 查看将要导入的内容(演练模式)
yarn import-partitions --source '/path/to/Partitions_Export_...' --dry-run

# 执行实际导入
yarn import-partitions --source '/path/to/Partitions_Export_...'
CLI 导入示例:共 8 个账号,成功 8 个,跳过 0 个,失败 0 个

  当时一人集中扫码,其他同事导入后就能接着用,省去了逐个登录的麻烦。最开始为了做隔离而引入的 partition,到这里,把账号切换、后台下载和账号共享这几件事都串起来了。

上线之后

  当时有一个 10 人的运营支持团队,专门负责下载各个门店的日常报表并上传到业务系统。天工内测灰度期间,他们一直充当最积极的核心用户,每天帮我们做真机测试,反馈了一大堆边界 case 和体验问题。工具能一点点跑顺、稳定下来,他们功不可没。

  2025 年 9 月 4 日,运营支持团队的 leader 在飞书群里公开表扬了天工的速度与准确率,还贴上了组员们的实测好评:79 个账号、3 天的经营参谋报表,Mac 端只花了 17 分钟就下载完了。

2025 年 9 月 4 日,运营支持团队在群里对天工下载速度和准确率的反馈

  作为研发,做提效工具能收到一线业务方这样真诚正向的反馈,当时真的成就感爆棚。那些他们以前每天机械重复、烦躁不堪的操作,终于全部解脱交给代码了。

  工具彻底稳定运行之后不久,这位 leader 和整个运营支持团队,被全部优化裁员了。

  现在回过头再看群里的那张截图,心里挺五味杂陈的。他们在群里真诚地感谢我们,热情地帮我们找 bug、测场景,一起把工具打磨成熟。但讽刺的是,这个工具最终替代掉的,恰恰就是他们自己的工作岗位。

  天工确实是一次非常成功的业务提效实践。只是作为亲手写下这些代码的人,面对这样的终局,很难再单纯地为它感到高兴。

参考

留言

欢迎交流想法,也欢迎指出文中的问题。

本文目录

查看大图

在文章里找一找

搜索标题和正文,直接跳到相关章节。