中文English

GatherSurf 应用开发文档

这份文档里讲的每个接口,都能在示例工程「选品助手」里找到对应的可运行代码。 新建应用时选「📘 示例工程」就能得到它——那是一个完整能跑的应用,改改就能发布。
工程里还带一份 AGENTS.md,可以直接喂给 AI 让它按你的需求改(见交给 AI 开发)。

这是什么

你可以在 GatherSurf 客户端里开发自己的应用,它和我们自己上架的应用跑在完全同一条路上—— 没有「开发模式特权」,也没有「上架后才有的限制」。这条约束是有意的:否则会出现 「我本地好好的,上架就坏」,而那种问题最难查。

你写的跑在哪能拿到什么
main.js客户端主进程host 对象——按你声明的权限给
ui/客户端界面(同一个 window)gsApp 对象——只有它
为什么界面拿不到别的东西

应用界面是片段,直接挂进宿主页面(不是 iframe)。好处是能直接用宿主的 CSS 变量, 主题和配色自动跟随,也不用做通信桥。代价是必须靠约定隔离——所以你的脚本在函数作用域里跑, 全局只注入 gsApp,够不到宿主的任何内部状态。

🔎 我想做…

这份文档是按接口组织的,但你脑子里想的多半是一件事。 先在这儿对一下,直接跳到该看的地方。

我想…去看
让 AI 帮我写,我只描述需求 交给 AI 开发 —— 新建应用项目、说一句话就行
存点东西下次还在(配置、结果) 几十条以内用 storage(就是读写文件); 要查询、要统计、上千条 → db
调别人家的 API 拿数据 http —— 直接用就行,不需要先申请域名。IP、内网地址、本机服务都能访问
列出用户的浏览器环境 / 建号 / 改号 profiles(读、开关、增删改分三档权限,按需申请)
打开一个号,在页面上点点填填、抓数据 automation;多步骤的活儿直接用 runSteps 跑一条 RPA 流程更稳
做一件要跑很久的事(批量建号、逐个测速) 进度事件 —— 必须边跑边推, 不然界面几十秒不动,用户以为卡死了
界面想用 Bootstrap / 现成的 HTML 搬进来 写界面 —— manifest 里声明一句就能用平台内置的三个框架
让用户选个文件 / 把结果导出成 CSV files:pick
写完了,怎么让别人用上 规范自查三种发布
我不写应用,只想从外部(Python/Node)驱动客户端 外部接口 —— 那是另一件事,和上面这些没关系
动手之前先扫一眼 避坑速查

那一节列的都是不报错的问题——写的时候顺手避开,比事后查一天划算。 共同点是"看起来在工作,其实没有"。

五分钟跑起来

两条路,选一条。都不需要装任何东西——客户端本身就是开发环境。

路 A:让 AI 写(不用会 JS)

  1. 客户端左边 →「✨ AI 开发」→ 右上角「+ 新应用」
  2. 填个中文名(比如「代理测速台」),标识只填后半截,前缀是自动加的
  3. 用大白话说你要什么:
    「做一个代理测速台:列出我所有代理,点全部测速, 显示延迟和是否可用,能按延迟排序,坏的标红」
  4. 它会先问你界面用哪套框架——点一下选就行(不确定就选推荐的那个)
  5. 生成完点「▶ 预览运行」——直接跑起来看效果
  6. 不满意就接着说:「表格加一列地区」「把测速改成并发 5 个」
  7. 满意了点「📦 放进我的开发」,它就成了一个正式应用,可以发布

路 B:自己写

  1. 「应用中心」→「🛠 我的开发」→「+ 新建应用」
  2. 选「📘 示例工程」——那是一个完整能跑的应用,每个接口都有可点的按钮
  3. 点「打开」跑一遍,看哪个接口是你要的
  4. 「📂 打开开发目录」,用你惯用的编辑器改 main.js
  5. 存盘,回到客户端——会自动重载,不用重启
最小的一个应用只有这么点东西
main.js                                 // 后端
  exports.register = function (host) {
    host.ipc.handle('hello', async () => ({ ok: true, msg: '你好' }));
  };

ui/index.html                           // 界面(是片段,没有 html/body)
  <button id="ab-go">点我</button><div id="ab-out"></div>

ui/app.js                               // 界面逻辑(必须是 IIFE)
  (function () {
    document.getElementById('ab-go').onclick = async () => {
      const r = await gsApp.invoke('hello', {});
      document.getElementById('ab-out').textContent = r.msg;
    };
  })();

剩下的 manifest.json(声明权限和入口)和 README.md 是必须有的, 新建时会给你生成好。

自动重载没生效怎么办

先点⚙设置里的「手动重载」。还不行的话,多半是你改的文件不在开发目录里—— 工具栏右侧显示的那个路径才是客户端真正读的地方。
「改了文件、跑的还是旧代码」是最难察觉的一类问题:加了日志不出现,人会去怀疑日志代码, 而不是怀疑文件根本没生效。

交给 AI 开发

示例工程里带了一份 AGENTS.md,是专门写给 AI 读的: 全部接口签名、硬约束、常见错误、怎么改怎么发布,都在里面。

三步

  1. 新建应用时选「📘 示例工程」,得到一份能跑的完整代码
  2. 点「📂 打开开发目录」,把整个目录(含 AGENTS.md)交给你的 AI
  3. 直接说你要什么——「把 1688 换成淘宝」「加一个自动比价提醒」「导出改成 Excel」
为什么要有 AGENTS.md,而不是让 AI 自己读代码

代码里看不到的东西有一半:哪些全局被运行时遮蔽了、样式前缀怎么推导、 自查会拦什么、host.db 为什么必须加 LIMIT、 白名单现在是记录还是拦截。AI 靠猜会写出语法完全正确但打不了包的代码。

可以直接复制的开场白

这是 GatherSurf 客户端的一个应用工程。
先读 AGENTS.md,它写明了全部接口、硬约束和常见错误。
读完后按我的需求改,注意:
  - 不要 require('electron') / require('fs')
  - CSS 每条选择器都要带工程里已有的前缀
  - ui/app.js 必须保持 IIFE
  - 改表结构只能在 ensureSchema() 里往后加版本,不能改已有的
我的需求是:______
AI 改完之后你要做的

点⚙设置里的规范自查。它检查 25 项机器能判定的东西—— AI 最容易漏的是样式前缀和 IIFE,自查会当场指出来。
自查过了再点「打开」实际跑一遍:自查只管形式,不管你的逻辑对不对。

目录结构

你的应用/
├─ manifest.json     你是谁、要什么权限、入口在哪
├─ main.js           后端逻辑(主进程)
├─ README.md         必须有,自查会检查
└─ ui/
   ├─ index.html     界面片段(不是完整文档)
   ├─ style.css      样式(每条选择器都要带前缀)
   └─ app.js         界面逻辑(必须是 IIFE)
不能有 node_modules

应用包不带依赖。你只能用 Node 内置的纯计算模块(pathcryptourl 这类), 不能 require 第三方包,也不能 require 带 I/O 的内置模块—— 要文件用 host.storage,要网络用 host.http

manifest.json

{
  "key": "abc12-erp-sync",        // 你的应用标识,建好不能改
  "name": "ERP 同步",
  "icon": "🚀",                   // emoji 或包内图片路径
  "version": "1.0.0",             // 必须是 x.y.z
  "description": "一句话说明",
  "apiVersion": 1,
  "minClientVersion": "0.3.10",   // 低于这个版本的客户端不给装
  "permissions": ["storage", "db", "http"],
  "http": { "allow": ["api.mycompany.com"] },   // 选填;目标域名固定时建议如实写
  "entry": { "main": "main.js", "ui": "ui/index.html" }
}
key 建好之后不能改

它同时是三样东西:本地目录名、上架后的 OSS 路径、IPC 路由键。 前缀(你的账号命名空间)是强制的——不带的话,两个客户建了同名应用会互相覆盖, 而且是静默覆盖:B 客户装上 A 客户的代码。

权限一览

权限给你什么备注
storagehost.storage几乎都要
dbhost.db要付费档位
httphost.http白名单选填;固定域名建议写
profiles:readhost.profiles 只读
profiles:control加上开/关窗口
profiles:write建号/改号/删号/配代理能删号,不可逆
automationhost.automation审核重点看
files:pickhost.files只能弹框让用户选
没声明的权限,对应的对象就是 undefined

所以用之前先判断。这不是啰嗦——用户可能装的是没开商业版的客户端, host.db 就是空的,直接用会抛 Cannot read properties of undefined

if (!host.db) return { ok: false, error: '没有 db 权限' };

平台还会把「声明了但没拿到」的能力放在 host.locked 里, 连原因和解锁办法一起给。把它显示在界面上,比让用户面对一个点了没用的按钮强得多。

接口速查表

能力方法
host.ipc
不需要权限
handle(name, fn)send(name, payload)
host.storage
storage
同步调用
dir()list()read(n)readJson(n, d)remove(n)write(n, t)writeJson(n, o)
host.db
db
同步调用
close()exec(sql, params)migrate(list)path(值)query(sql, params, o)tx(fn)
host.http
http
都要 await
allowed()fetch(url, init)json(url, opt)mode()request(url, opt)
host.profiles
按方法区分,见右
都要 await
addProxy(input) profiles:writecheckProxy(id) profiles:readclose(id) profiles:controlcreate(input) profiles:writeget(id) profiles:readgroups() profiles:readlist() profiles:readopen(id) profiles:controlproxies() profiles:readremove(id) profiles:writerunning() profiles:readupdate(id, input) profiles:write
host.automation
automation
都要 await
closeTab(id, tabId)dwell(id, opt)evaluate(id, expr, opt)newTab(id, url)realClick(id, x, y, opt)realKey(id, key, opt)realType(id, x, y, s, opt)runSteps(id, steps, vars)screenshot(id, opt)tabs(id)
host.files
files:pick
都要 await
pickOpen(opts)pickSave(opts, data)

不需要权限
host.apiVersion host.appKey host.canDevelop host.config host.locked host.log() host.plan

这张表由平台代码直接生成,和 AI 开发助手拿到的是同一份。 表里没有的方法就是不存在的——写了会拿到 undefined,运行时报 is not a function

后端接口 host.ipc

示例应用:main.js 第 0 节

平台只调你的一个导出:

exports.register = function register(host) {
  const { ipc, storage, db, log } = host;

  ipc.handle('hello', async (payload) => {
    log('收到', payload);            // 写进客户端日志,调试用
    return { ok: true, msg: '你好' };  // 原样回到界面
  });
};

// 可选,但强烈建议实现
exports.deactivate = function () { /* 停掉你的定时器 */ };
约定:出错回 {ok:false, error:'人话'},别直接抛

抛出去界面只能看到一句框架的报错,用户不知道发生了什么,你也拿不到现场。

deactivate 里必须真的把定时器停掉

不停的话:用户关掉应用界面了,你的调度还在后台跑,还在操作他的窗口。 这是审核会专门看的一条。

界面 gsApp

示例应用:ui/app.js
成员说明
gsApp.appKey你的应用标识
gsApp.config云端给这个客户的配置(每个客户可以不同)
gsApp.invoke(name, payload)调后端,返回 Promise
gsApp.on(name, fn)收后端推来的事件
gsApp.toast(msg, isErr)弹一条提示
gsApp.openExternal(url)用系统浏览器打开链接
const r = await gsApp.invoke('hello', { name: '张三' });
if (r.ok) console.log(r.msg);
else gsApp.toast(r.error, true);

进度事件

示例应用:main.js 第 8 节 / ui/app.js 底部

长任务不能等跑完才返回——用户会以为卡死了。

// 后端
ipc.send('progress', { i: 3, total: 10, msg: '第 3 步完成' });

// 界面
gsApp.on('progress', (p) => {
  bar.style.width = (p.i / p.total * 100) + '%';
});

文件存储 storage

什么时候用它存几十条以内的东西:配置、上次的选择、一份结果快照。就是读写文件,同步的,不要 await
什么时候别用要查询、要统计、要上千条 —— 用 db。storage 只能整个读出来在 JS 里过滤,条数一多就卡。
示例应用:main.js 第 1 节

全部是同步的,不要加 await

storage.dir()                      // 你的数据目录绝对路径
storage.list()                     // 你存了哪些文件(文件名数组)
storage.read(name)                 // 文本,读不到回 null
storage.write(name, text)          // 原子写(先 .tmp 再 rename)
storage.readJson(name, 默认值)      // 读不到 / 坏了都回默认值
storage.writeJson(name, obj)
storage.remove(name)               // 删除,回 true / false
write 的第二个参数必须是字符串

传对象请用 writeJson;想删掉一个文件请用 removewrite(name, null) 不是删除的写法,会抛 TypeError

路径是宿主拼的,你只给文件名

../../别人的文件 会被拒。路径由平台拼, 所以「越界」在架构上不可能发生,不依赖代码审查去发现。

什么时候该换成数据库

只要你需要查询、排序、统计、分页。JSON 一旦上千条,每次都要全量读进内存 再自己 filter,而 SQL 走索引是微秒级的。这条线比想象中来得早。

数据库 db 要付费档位

什么时候用它要查询、排序、统计,或者条数上千。SQLite,同步的,跑在主进程
什么时候别用只是记住"用户上次选了哪个" —— 那用 storage,不值得为一个字段建库。
示例应用:main.js 第 2 节(2.1 ~ 2.7)
先读这一条:它是同步的,跑在主进程里

一次查询有多慢,整个客户端就卡多久——所有窗口操作全停。 实测五万行的库:聚合报表 13ms、走索引 0.21ms 都无感, 但 SELECT * 一次捞五万行要 162ms,主进程完全冻住。

所以平台加了行数上限:一次返回超过上限直接报错,不是悄悄截断—— 悄悄截断会让你的报表算出错误的合计,那比报错危险得多。

=> 统计交给 SQL(SUM / COUNT / GROUP BY),别捞回来自己算。

API

db.query(sql, params)      // 读,返回行数组(有行数上限)
db.exec(sql, params)       // 写,返回 { changes, lastId }
db.tx(fn)                  // 事务,fn 里抛错就整体回滚
db.migrate(list)           // 版本化建表/改表
db.path                    // 数据库文件路径

建表:用 migrate,不要用 CREATE TABLE 硬跑

db.migrate([
  { v: 1, name: '建客户表', up: [
      `CREATE TABLE IF NOT EXISTS customers(
         id     INTEGER PRIMARY KEY AUTOINCREMENT,
         name   TEXT    NOT NULL,
         amount INTEGER NOT NULL DEFAULT 0,   -- 金额用整数「分」
         created_at_ms INTEGER NOT NULL       -- 毫秒,直接存 Date.now()
       )`,
      `CREATE INDEX IF NOT EXISTS idx_customers_at ON customers(at)`,
  ]},
  { v: 2, name: '加备注', up: [`ALTER TABLE customers ADD COLUMN remark TEXT`] },
]);
已经发布出去的迁移不能改,只能往后加

用户的库可能停在任意版本——他上个月装的 v1,今天升到 v3,migrate 会按 v 补齐他缺的那几步。 改历史迁移的后果是:老用户升不上来,而且报错发生在他机器上,你看不到。

增删改查

// 参数一律用 ? 占位,不要自己拼字符串。
// 拼字符串的问题不只是注入——名字里有个单引号你的 SQL 就崩了。
const r = db.exec('INSERT INTO customers(name,amount,at) VALUES(?,?,?)',
                  ['张三', 12800, Math.floor(Date.now()/1000)]);
// r.lastId 是自增 id

const rows = db.query('SELECT * FROM customers WHERE amount > ? ORDER BY id DESC LIMIT 50',
                      [10000]);   // ★ 一定要 LIMIT

db.exec('UPDATE customers SET amount=? WHERE id=?', [20000, r.lastId]);
db.exec('DELETE FROM customers WHERE id=?', [r.lastId]);

硬上限

超了会怎样
单次查询返回行数20000报错,不是静默截断
单个应用的库文件512 MB拒绝写入
单个字符串 / blob64 MB报错(大文件别塞库,用 storage 存路径)
为什么行数超限是报错而不是给你前 20000 行

悄悄截断会让你的报表算出一个看起来正常、实际是错的合计—— 那比报错危险得多。要全量就分页,要统计就交给 SQL 算。

统计:让 SQL 算

const byCity = db.query(`
  SELECT city, COUNT(*) AS n, SUM(amount) AS total
  FROM customers GROUP BY city ORDER BY total DESC LIMIT 20`);

事务

try {
  db.tx(() => {
    const a = db.query('SELECT amount FROM customers WHERE id=?', [from])[0];
    if (a.amount < amt) throw new Error('余额不够');   // 抛错 = 整体回滚
    db.exec('UPDATE customers SET amount=amount-? WHERE id=?', [amt, from]);
    db.exec('UPDATE customers SET amount=amount+? WHERE id=?', [amt, to]);
  });
} catch (e) {
  // 走到这里数据还是转账前的样子
}

什么时候必须用:多条写操作在业务上是一件事。典型是「扣一边、加一边」—— 中间崩了就出现钱凭空消失。示例应用里有个「故意失败(验回滚)」按钮,点一下就能眼见为实。

数据库放在哪

每个应用一个独立文件,锁在你自己的数据目录里,别的应用碰不到。 删除应用时只删源码,数据目录保留——所以误删代码不会丢数据。

出网 http

什么时候用它调别人家的 API、把数据同步到你自己的服务器。域名、IP、内网地址、localhost 都能访问,不需要预先申请
什么时候别用想抓一个网页上渲染出来的内容 —— 那要用 automation 打开页面取,http 拿到的是原始 HTML,很多站点的内容是 JS 渲染出来的。
示例应用:main.js 第 3 节

manifest 里只要声明 "http" 权限就能用。域名白名单是选填的 (见下面「域名白名单是选填的」):

"permissions": ["http"]

// 想告诉用户你会访问哪些地方,就再加这一行(选填)
"http": { "allow": ["api.mycompany.com", "192.168.1.10:8080"] }

三个入口,按你要什么选:

// ① 取 JSON —— 最常用
const r = await http.json('https://api.mycompany.com/orders');
r.data          // ← 解析好的对象在这里
r.status        // 200

// ② 要看状态码 / 响应头,或者对方不一定回 JSON
const r2 = await http.request('https://api.mycompany.com/ping');
r2.status  r2.ok  r2.headers  r2.text

// ③ 和标准 fetch 同签名,需要流式或特殊用法时用
const r3 = await http.fetch('https://api.mycompany.com/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(data),
});
const text = await r3.text();

http.allowed()   // 白名单里有哪些域名,可以显示给用户看
http.mode()      // 当前的白名单执行模式
json() 的解析结果在 .data 里,不是返回值本身

写成 const data = await http.json(u) 之后再取 data.xxx, 拿到的全是 undefined,而且不报错。正确写法是 const r = await http.json(u); r.data.xxx

另外:对方返回的不是合法 JSON 时 json()抛异常request() 不会)。 不确定对方回什么就用 request() 自己判。

白名单只写域名

"api.mycompany.com"——不要带协议(https://)、不要带路径、 不要写 *.com 这种顶级域通配。带协议的写法比对的是 URL 的 hostname, 永远匹配不上,结果是每个请求都被判为白名单外。自查(devcheck)会拦下这几种写法。

为什么要白名单

禁的不是出网——客户的数据本来就该能同步到他自己的系统。禁的是看不见的出网。 域名写在 manifest 里,上架审核看得见,客户装的时候也看得见。

重定向:跳到白名单里的域名会跟随,跳出去不跟

对方回 301/302 时:

跟到已经授权的域名不增加任何暴露面——应用本来就能直接请求它。 真正的风险是跳出白名单:你信任的域名回一个 302 指向别处,数据就送出去了。

★ 实际会遇到:GitHub 的 api.github.com/repos/facebook/react 回 301 指向它自己(仓库改名了)。不跟随的话就拿不到数据。

硬上限

超了会怎样
单次响应体8 MB报错(大文件请分页/分片)
频率600 次 / 分钟报「出网请求过于频繁」
超时30 秒中断请求(可用 opt.timeout 调)

★ 写在这儿是因为:撞上了才知道有这个数的话, 错误信息出现时人的第一反应是"我代码写错了",而不是"我超了上限"。

域名白名单是选填的

不写也能请求任意地址

域名、IP、192.168.x.x:8080 这类内网地址、localhost ——都能直接访问, 不需要预先申请host.http.mode() 返回当前模式,现在是 permissive:白名单外的域名只记录、不拦截。

那写它干什么?它是给人看的——用户装应用时能看到"这个应用会访问哪些地方", 上架审核也会看。要写就如实写,用到哪些写哪些。

★ 一个例外:"*""*.com" 这种会被拒绝。 写一个等于没有的白名单,比不写更误导——看的人会以为已经限制过了。

但这一半是已经强制的:裸 fetch 用不了

应用作用域里的 fetch / XMLHttpRequest / WebSocket遮蔽了,调了会告诉你「请改用 host.http」。globalThis.fetch 也一样—— 遮蔽的是词法作用域。

为什么两半要一起做:只加白名单不摘裸 fetch,应用直接绕过去,白名单形同虚设, 而且审核看不出来。只做一半等于没做。

窗口 profiles:read profiles:control profiles:write

什么时候用它列出用户的浏览器环境、开关窗口、建号改号。权限分三档(read / control / write),按需申请,多要会被审核问
什么时候别用拿不到代理密码和平台账号密码,指纹也是云端生成的,你不参与。
示例工程:「窗口」那一页
// profiles:read —— 看
await profiles.list()      // [{ id, customNo, name, group, platform, lastOpenedAt, locked }]
                           //   lastOpenedAt = 级时间戳,从没开过是 null(JS 里要 ×1000 才能 new Date)
                           //   locked = true 表示超出套餐配额、这个号打不开(数据还在)
await profiles.get(id)
await profiles.running()   // id 的字符串数组:["p1","p2"],不是对象数组

// profiles:control —— 操作已有的号
await profiles.open(id)
await profiles.close(id)

// profiles:write —— 改变账号里有什么
await profiles.create({ name, group, platform, proxyId, proxyLine, remark })
await profiles.update(id, { name, group, remark, proxyId })
await profiles.remove(id)                    // 不可逆
await profiles.proxies()                     // 代理库 —— 只要 :read(只是列出来)
await profiles.addProxy({ name, line })       // line = socks5://user:pass@ip:port
await profiles.checkProxy(id)             // → { ok, ip, country, ms, error };测通不通 + 耗时
                                          //   ★ 只要 profiles:read(它不改任何东西)
await profiles.groups()                      // [{ id, name, count, builtin?, orphan? }] —— 只要 :read
                                          //   ★ 里面有两类不是真分组的行,见下
groups() 返回的不全是"建出来的分组"

三类行,靠字段区分(2026-08-05 起):

对这两类调改名/删除会被拒(400)。要遍历删除分组,先过滤: groups.filter(g => !g.builtin && !g.orphan)

★ 为什么要把它们发出来:不发的话各组 count 加起来比号的总数少, 而少掉的那些号在分组里一个都找不到 —— 实测某账号少了 8 个,且哪里都不报错。

running() 回的是 id 数组,不是对象数组

要关掉所有窗口就直接遍历它:for (const id of await profiles.running()) await profiles.close(id)。 写成 .map(p => p.id) 会得到一串 undefined,而且不报错—— 后面每一次 close(undefined) 都静默失败,窗口一个都没关掉。

代理只能加,不能删

addProxy 加进去的代理留在账号的代理库里,接口这边没有删除入口—— 写测试或者试用时注意,别在客户账号里堆垃圾。

为什么 controlwrite 是两个权限

control 是「操作已有的号」,write 是「改变账号里有什么」—— 建号占配额、删号不可逆、改代理换出口 IP。合成一个的话,一个只想开关窗口的应用 会被迫拿到删号的能力,而用户装的时候看到的权限说明也就失去了区分度。

建号时你不管指纹

指纹是云端生成的。你只说要什么平台、哪个分组、用哪条代理,剩下的交给平台—— 这样同一个账号下的指纹策略是统一的,也不会因为某个应用生成得不对而露馅。

配额错误要原样透传

建号会撞云端的三层门禁(未登录 / 无权限 / 超出上限)。 把它包装成一句「创建失败」,用户就完全不知道是要升级套餐还是先删几个号。

代理用「一行式」串,别自己拆

await profiles.addProxy({ name: '香港节点', line: 'socks5://user:[email protected]:1080' });

云端负责解析成 type/host/port/user/pass。你自己拆的话,拆法一变两边就不一致—— 而不一致的表现是「代理配了不生效」,查起来很费劲。

拿不到密码

代理密码、平台账号密码不出这一层——不是靠前端隐藏,是接口里根本不返回。

页面操作 automation 审核重点看

什么时候用它打开一个号,在页面上点击、输入、滚动、截图、抓数据。
什么时候别用多步骤的活儿优先用 runSteps 跑一条 RPA 流程 —— 比自己拼 evaluate + realClick 稳得多,代码也短。
示例应用:main.js 第 5 节

第一个参数都是窗口(profile)的 id。要先 profiles.open(id)

await automation.newTab(id, url)          // → { id, url };只允许 http/https
await automation.tabs(id)                 // → [{ id, url, title }]
await automation.closeTab(id, tabId)      // → { ok, closed } / { ok:false, why };用完要关
await automation.evaluate(id, 'document.title')   // → 表达式的值本身(不写 return)
await automation.realClick(id, '#su')     // 直接传选择器
await automation.realType(id, '#kw', '文字')
await automation.realClick(id, x, y)      // 也支持坐标(少用,见下)
await automation.realKey(id, 'Enter')
await automation.screenshot(id, {})       // → { ok:true, base64:'…' }
await automation.dwell(id, { scrolls: 4 })        // → { dwelled, scrolls, wander };拟人停顿
profiles.open() 返回 ≠ 窗口能用了

内核冷启动在忙的机器上要十几秒。别写固定 sleep(3000) —— 快的时候白等、慢的时候照样失败,换台机器必坏。 用 tabs(id) 轮询:能拿到数组就说明起来了。

还有一种打不开是"不该打开"(2026-08-05 起):号数超过套餐配额时, 只有最新的 N 个能开,其余的数据完整保留但拒绝启动 (list()locked: true 的就是这些)。 拿到的错误里会写清楚是"套餐到期"还是"纯超配额"。 批量开号前先按 locked 过滤 —— 不过滤的话循环里每个都失败一次, 而失败原因和"内核起不来"混在一起,很难看出真因。

newTab 之后把 tabId 传下去
const tab = await automation.newTab(id, url);
await automation.evaluate(id, expr, { tabId: tab.id });
await automation.realType(id, '#kw', '文字', { tabId: tab.id });

不传的话平台用"你最近开的那个"兜底,绝大多数时候是对的。 但窗口打开时会异步恢复上次的标签页,慢的时候它们会插到后面 —— 显式传 tabId 才是确定的。

(这一条实测撞到过:探测输入框探到的是上一轮留下的空白页, 报"未探测到可见输入框",而且时好时坏 —— 取决于恢复得快还是慢。)

不要凭记忆写站点的选择器 —— 先探测

站点会改版,而改版后最常见的结果不是"找不到元素", 是元素还留在 DOM 里、但已经是 0×0 的隐藏残留: 你的代码一路"成功",输入框里却什么都没有。

实测(2026-08-03):百度的 #kw / #su 现在就是这种残留, 真正的输入框是 #chat-textarea、按钮是 #chat-submit-button。 而所有模型的训练数据里全是 #kw,让 AI 写必错。

// 先探一次,用探到的
const sel = await automation.evaluate(id, `(() => {
  for (const el of document.querySelectorAll('input,textarea')) {
    const b = el.getBoundingClientRect();
    if (b.width > 60 && b.height > 16 && el.id) return '#' + el.id;
  }
  return '';
})()`);
await automation.realType(id, sel, '指纹浏览器');

width>60 && height>16 这个过滤是关键:它把隐藏的残留直接筛掉了。

千万别硬编码坐标

realClick(id, 300, 200) 点在空白处不会报错 —— 每一步都"成功",只是什么都没发生。你通常只知道选择器,就传选择器: 平台会解析出元素矩形,落点仍然在框内随机漂移(不是像素级瞄准, 这正是 realClick 存在的理由)。

runSteps:直接跑一条 RPA 流程

多步骤的活儿用它比自己拼 evaluate + realClick 稳,代码也短得多。 步骤格式和「自动化」页的积木完全一致。

const r = await automation.runSteps(id, [
  { op: 'goto', url: 'https://example.com' },
  { op: 'waitForSelector', selector: 'h1' },
  { op: 'evaluate', code: 'return document.title' },
]);
if (!r.ok) return { ok: false, error: r.error };   // ← 失败不抛异常,必须自己判 .ok
r.results     // 每一步一个对象:[{ op, ok, value, error }, ...]
r.vars        // 流程里 saveTo 存下来的变量
results 是步骤对象的数组,不是值的数组

r.results[r.results.length-1] 拿到的是那个对象,不是那一步的结果。 拿去比大小恒为假 —— "成功"会被判成失败,而 error 是 undefined, 界面上只剩一句"未知错误"(2026-08-03 实测撞到)。

要值就 r.results[i].value更稳的做法是给那一步加 saveTo:'名字',之后从 r.vars.名字 取 —— 按下标取的写法,中间插一步就全串位了。

RPA 的 evaluate 要自己写 return

code语句块(被包在 async 函数里执行),漏了 return 就是 value: undefined,而这一步照样 ok: true —— 结果凭空消失且不报错。

{ op:'evaluate', code:'return document.querySelectorAll(".result").length' }

★ 注意和 automation.evaluate(id, expr) 相反:那个收的是表达式, 不要写 return。两个名字一样、约定相反,是最容易搞混的一处。

op 字段是必须的——写成 typeaction 会得到「未知 RPA 步骤:undefined」。
不判 .ok 的话,流程里每一步都失败你也只会看到"执行成功", 因为 runSteps 是把错误放在返回值里的。

evaluate 的返回值必须能 JSON 序列化

返回 DOM 节点会拿到一个空对象 {},而且不报错。 要元素的信息就在表达式里取出来:'document.querySelector("h1").textContent'

只允许 http/https,file:// 会被拒

否则一个只有 automation 权限的应用就能用浏览器去读磁盘上任何文件—— 数据库沙盒被整个绕过(它防的是 SQL,防不了浏览器),.gs-auth 之类的凭证也一样。
沙盒的强度 = 同时授出的所有能力里最弱的那个。

标签页用完要关

每个标签页都是一个渲染进程。任务一轮轮跑而不关,内存会一直涨。

为什么有 realClick 而不是直接 evaluate 里 el.click()

很多站点不认合成事件。数量输入框尤其典型:用 value= 赋值不会进 React 的状态, 页面显示变了、提交的还是旧值——而且不报错。

实操:开窗口 → 上 1688 → 搜商品 → 落库

示例应用:main.js 第 5.5 节 · 界面「★ 实操」那一节

前面几节讲单个接口怎么用,这一节是它们串起来长什么样——真实业务里你要写的就是这种流程。

七步

  1. 选窗口——没指定就用第一个。真实应用里应该让用户选或按分组轮换, 闷头用第一个在多账号场景下会一直用同一个号
  2. 确保它开着——已经开着就跳过,重复开会走一遍完整启动,白等好几秒
  3. 直接拼搜索 URL 开标签页,不去首页点搜索框——能用 URL 表达的意图就别用点击表达, 少一步就少一个会坏的地方
  4. 轮询等商品渲染出来(见下)
  5. evaluate 在页面里抓数据
  6. 截图存证
  7. 一个事务批量写进数据库
不要用固定 sleep 等页面

sleep(5000):网快时白等,网慢时不够——两头都不对。 正确做法是轮询到出现为止并设一个上限:

let found = 0;
for (let i = 0; i < 20; i++) {
  found = await automation.evaluate(pid,
    `document.querySelectorAll('[class*="offer"]').length`);
  if (found > 0) break;
  await new Promise(r => setTimeout(r, 700));
}
if (!found) return { ok:false, error:'页面上没找到商品 —— 可能要登录、被风控、或者改版了' };

这是自动化里最容易写错的一步。写死等待的脚本在别人机器上一定会坏, 而且坏的时候看起来像「网站有问题」。

选择器写松一点,抓不到要说清楚

电商页面改版很频繁,写死一个 class 的抓取代码活不过一个月。 用 [class*="offer-item"] 这种模糊匹配加多个兜底。

更重要的是:抓不到时不要返回空数组。空数组会被上层理解成「没有商品」, 然后一路正常走下去,最后得到一份空报表——而真相是页面要登录。 要明确回报「没找到,可能是这几个原因」。

标签页要在 finally 里关
try {
  tabId = (await automation.newTab(pid, url)).id;
  // …中途任何一个 return 分支…
} finally {
  if (tabId) await automation.closeTab(pid, tabId);
}

每个标签页都是一个渲染进程。放在 try 末尾的话,中途 return 的分支就漏掉了—— 任务一轮轮跑,内存一直涨到客户端卡死。

evaluate 里的代码跑在页面里

const items = await automation.evaluate(pid, `
  (function () {
    var out = [];
    document.querySelectorAll('[class*="offer-item"]').forEach(function (c) {
      var t = c.querySelector('[class*="title"]');
      if (t) out.push({ title: t.innerText.trim() });
    });
    return out;      // ★ 必须能 JSON 序列化
  })()`);
返回值必须能 JSON 序列化

返回 DOM 节点会拿到一个空对象 {}——不报错,只是什么都没有。 这是新手最常撞的一个坑。

批量写库用一个事务

db.tx(() => {
  for (const it of items) db.exec('INSERT INTO offers(...) VALUES(?,?,?)', [...]);
});

十条分十次提交 = 磁盘同步十次,慢一个数量级。

失败时把「走到第几步」带回去

示例里每一步都记进 steps 并实时推给界面。 出问题时用户看到的是「卡在等待商品加载」,而不是笼统一句「失败了」—— 前者他自己就能判断是要登录还是网络慢。

示例只做只读操作

开窗口、搜索、读列表、截图——不点加购、不下单。那些动作有真实后果, 示例代码不该替你做。你要做的话,务必先让用户确认,并且把「做了什么」记下来。

文件选择 files:pick

什么时候用它让用户选一个文件读进来,或者把结果保存到他指定的位置。
什么时候别用弹的是系统对话框,会阻塞。别放在批量循环里 —— 跑到第三个弹一次,用户以为程序卡住了。
示例应用:main.js 第 7 节
const f = await files.pickOpen({
  title: '选一个 CSV',
  filters: [{ name: '表格', extensions: ['csv', 'xlsx'] }],
});
if (!f) return;                       // 用户点了取消,不是错误
f.name; f.size; f.path; f.bytes;      // bytes 是 Buffer

await files.pickSave({ title: '导出', defaultPath: 'out.csv' }, buffer);
没有 readFile(路径)

那等于给应用开了任意读。用户点了哪个文件,你才拿得到哪个。

这两个是模态的:用户不选,它就不返回

所以 pickOpen / pickSave 各自要有专用的 ipc 接口, 那个接口只做这一件事,界面上给它一个单独的按钮。

不要把它和别的操作放进同一个接口、同一个循环、同一个「一键执行」里—— 同一个接口里其余的事会全部被这个对话框挡住,界面永远停在「处理中…」, 日志干净、没有异常、没有超时,是最难查的一类。在界面上提醒用户「这里会弹框」不解决问题, 被卡住的是代码不是用户。

自查(devcheck)会拦下把弹框和多个其它能力混在一个接口里的写法。

写界面的三条约束

示例应用:ui/ 三个文件

0. 先用平台组件,别从零写样式

平台提供一套 gs- 开头的组件类,直接用类名即可,不用自己写这些样式。 它们跟随客户端主题,和宿主界面天然一致。

gs-* 只能用,不能改

不要覆盖或重定义 gs- 开头的类 —— 那会连带影响宿主界面和别的应用。 需要不一样的样式,用你自己的前缀写一个新类(见下一节「CSS 每条选择器都要带前缀」)。

示例:一个典型的应用界面
支持多个词,用空格隔开
商品价格状态
无线蓝牙耳机 A1¥129.00 已入库
降噪耳机 B2¥299.00 跳过
运动耳机 C3 取价失败
一般说明用 gs-note-info
需要注意的用 gs-note-warn
出错了用 gs-note-bad

类清单

按钮

类名用途
gs-btn按钮(默认样式)
gs-btn-ghost幽灵按钮(无边框,次要操作)
gs-btn-pri主按钮(蓝底,一屏只该有一个)
gs-btn-sm小号,和上面几个叠加用

布局

类名用途
gs-card卡片容器(白底、圆角、细边)
gs-col纵向排列
gs-pad给卡片内部加内边距
gs-row横向排列,自动换行
gs-spacer占位撑开,把后面的元素推到右边
gs-toolbar顶部工具条(横向 + 下边距)

数据展示

类名用途
gs-empty空状态("还没有数据"那块)
gs-item列表的一行
gs-list列表容器

表单

类名用途
gs-field表单的一组(标签 + 控件)
gs-inp单行输入框
gs-label表单标签
gs-sel下拉框
gs-ta多行输入框
gs-table数据表格
gs-tag标签/徽标
gs-tag-bad红色标签(失败)
gs-tag-gray灰色标签(停用/中性)
gs-tag-ok绿色标签(成功)

标记与提示

类名用途
gs-hint控件下面的小字说明
gs-mono等宽字体(ID、路径、金额)
gs-note提示块(底色由下面三个决定)
gs-note-bad红色·出错了
gs-note-info蓝色·一般说明
gs-note-warn橙色·要注意
这套组件够不够用?

它覆盖的是业务界面的常见形状:工具条、卡片、表单、表格、列表、标签、提示。 碰到没有的(图表、日历、拖拽),自己用带前缀的类写 —— 组件库不做这些, 因为做了就要长期维护一套通用控件,而每个应用要的又都不太一样。

1. HTML 是片段,不是完整文档

不要写 <html> / <head> / <body>。它直接挂进宿主界面。

2. CSS 每条选择器都要带前缀

规则只有一条:ui/style.css 里每条规则、逗号分隔的每一段, 第一个选择器都必须以 .你的前缀- 开头。

.abc-wrap { }                ✅
.abc-wrap .title { }         ✅ 后代选择器 —— 已经被前缀圈在自己的子树里
.abc-btn:hover { }           ✅ 伪类
@media (max-width:600px){ .abc-wrap { } }   ✅ 媒体查询里照样要带
@keyframes abc-fade { }      ✅ 动画名建议也带前缀(避免和别人重名)

.wrap { }                    ❌ 裸类
button { }                   ❌ 客户端里每一个按钮都会被改掉
#panel { }                   ❌ id 是全局的
* { margin:0 }               ❌ 一句 reset 就能毁掉整个客户端界面
.abc-a, .navbar { }          ❌ 逗号后面那段也要带
.gs-btn { background:red }   ❌ 平台组件只能用,不能改

用第三方框架:平台已经内置了三个

交给 AI 开发的话,它会先问你用哪个

新建应用项目、说完要做什么之后,AI 的第一轮只问不写: 给出四个可点的选项(平台组件 / Bootstrap 5 / Milligram / Daft),选完才开始生成。

为什么是问而不是替你选:框架是选完就改不动的决定 —— 界面全部代码都建立在它上面,换一个等于整个 ui/ 重写。 一次点击换一次重写,值。

已经知道要用什么就直接说("用 Bootstrap 做一个…"),它不会再问。

manifest.json 里声明一句就能用,应用不带任何框架文件

{
  "key": "abc-demo",
  "ui": { "framework": "bootstrap5" },
  ...
}
framework说明体积
bootstrap5Bootstrap 5.3.3,含 JS 组件(模态框 / 下拉 / 折叠 / 标签页 / 轮播 / 提示气泡)CSS 258KB + JS 81KB
milligram极简,无类名 —— 写语义化 HTML 就有样式。纯 CSS22KB
daft无类名,现代风格(接近 shadcn/ui 的质感)。纯 CSS86KB
你的 HTML 里不用写任何平台特有的东西

框架的样式挂在一个作用域容器下,容器由平台自动包,你只管写正常的页面:

<!-- 声明了 bootstrap5 之后,直接这么写 -->
<div class="card"><div class="card-body">
  <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#m1">打开</button>
</div></div>

<!-- 无类名框架(milligram / daft)连 class 都不用 -->
<h2>标题</h2>
<form><input type="text"><button>提交</button></form>
<table>…</table>

这意味着现有页面可以直接搬过来,也意味着搬走时不用改回去。

样式前缀是从 app_key 算的,撞了可以自己指定

前缀 = key 每一段的首字母:abc-price-helperaph。 同一个开发者下重名是可能的(abc-batch-runabc-bulk-run 都是 abr)。

撞了不会互相污染——客户端每次只加载当前应用的样式,退出就移除。 但看代码时容易混,要区分开就在 manifest 里加:

{ "key": "abc-bulk-run", "uiPrefix": "abrun", … }

指定之后,自查和 AI 生成都会按你指定的这个来。

声明了框架,界面就一律用框架的类,别和 gs-* 混着用

混用会出现两套栅格、两套间距单位、两套控件高度 —— 看着能跑, 后面谁改都对不上。实测过一版输入框写成 class="gs-inp w-100", 高度取自 gs-inp、宽度取自 Bootstrap,改任何一边都只动一半。

AI 生成时也按这条走:声明了框架,提示词里就不会再出现 gs-* 清单

应用区内第三方优先

客户端自己的样式在这个容器里一律让开,不会盖住框架的同名组件。 所以 .btn.card 这些类拿到的是框架的样式,不是我们的。

要用内置以外的框架

可以,但要先做"作用域包装"。原样引入会被自查拦下 —— 这类框架第一件事就是重置全局*{box-sizing:border-box}body{margin:0}h1~h6 的字号…… 应用界面和客户端共用同一个文档,这些规则会命中宿主的元素, 把整个客户端的排版改掉,而且是装了这个应用的所有人都受影响。

做法:把框架的每一条规则前面加上你自己的一个容器类, HTML 里再套一层这个容器。

/* ❌ 原样 —— 自查会拦 */
*,::before,::after { box-sizing: border-box }
body { margin: 0 }
.btn-primary { ... }

/* ✅ 每条前面加 .abc-bs */
.abc-bs *, .abc-bs ::before, .abc-bs ::after { box-sizing: border-box }
.abc-bs body { margin: 0 }        /* 匹配不到东西,无害 */
.abc-bs .btn-primary { ... }
<!-- HTML 里套一层,里面照常写框架的类 -->
<div class="abc-bs">
  <div class="card"><div class="card-body">
    <button class="btn btn-primary">确定</button>
  </div></div>
</div>
自己包装的框架,同样受应用区约束

你自己带进来的遮罩、抽屉、弹层,只要是 position:fixed, 一样只铺到应用区边界,盖不到客户端 —— 这一条是平台在应用容器上做的, 和你用哪个框架无关。

包装可以让工具做,不用手改几千行:

# Sass:一行搞定
.abc-bs { @import "bootstrap/scss/bootstrap"; }

# PostCSS
postcss bootstrap.css --use postcss-prefix-selector \
  --postcss-prefix-selector.prefix ".abc-bs" -o scoped.css
三个前提
弹窗、遮罩只盖住应用区,盖不到客户端

应用区是 position:fixed包含块,所以应用里任何"铺满屏幕"的东西 (Bootstrap 的模态框、你自己写的遮罩层)最远只铺到应用区边界 —— 左侧导航和顶栏始终可点。

这不是为了好看:遮罩一旦因为任何原因没关掉(保存失败、脚本报错、 点到没绑事件的地方),用户如果连侧边栏都点不到,就没有退路, 只能关掉整个客户端。所以这一条由平台保证,你不用做什么。

顺带:Bootstrap 那两层挂在 body 上的 backdrop 被平台隐藏了, 灰底改由 .modal 自己提供 —— 视觉一样,范围受控。

先想想值不值

包装是一次性成本,但框架的样式和客户端本身的视觉语言是两套 —— 用户会明显看出"这一块是另外贴上去的"。 只是要按钮、表格、表单的话,上面那套 gs- 组件跟随客户端主题, 不用包装也不占体积。真正需要框架的场景是它有 gs-* 没有的东西 (栅格系统、模态框、日期选择器这些)。

前缀由 key 推导(每段首字母)。CSS 标识符不能以数字开头, 所以数字开头的会自动补一个 a8d7dk-examplea8e。 自查会告诉你该用哪个前缀。

为什么不用 iframe 隔离

用 iframe 就享受不到宿主的 CSS 变量(主题、配色),还得额外做通信桥。 选了同文档,就得靠前缀约定——所以自查逐条检查它。

3. app.js 必须是 IIFE

(function () {
  'use strict';
  // 你的代码
})();

不包一层的话,你的 let out = ... 会挂到 window 上,和宿主或别的应用撞名—— 而撞名的表现是「另一个应用的变量莫名其妙变了」,最难查的那种。

Electron 不支持 window.prompt

alertconfirm 可以用,唯独 prompt 不行—— 调了直接抛 prompt() is not supported,按钮表现为静默无反应。 要输入就自己做一个弹窗。

开发者 ID 与 app_key

发第一个应用之前,先在「应用中心 → 我的开发」注册一个开发者 ID(API 里的字段名是 devHandle)。 它会成为你所有应用的前缀,和 npm 的 @scope、Docker 的用户名、 VS Code 的 publisher 是同一个东西。

你注册的开发者 ID    smartmob
应用标识的后半截     sourcing        ← 只能小写字母/数字/连字符
                ↓
最终的 app_key      smartmob-sourcing

为什么必须带前缀

app_key 不只是个名字,它同时是三样东西

身份长什么样
云端的包路径apps/smartmob-sourcing/0.1.0/…
本机的应用目录名dev-apps/smartmob-sourcing/
IPC 路由键界面调后端时按它找到你的 main.js

所以两个开发者都建了个叫 report 的应用会互相覆盖, 而且是静默覆盖——B 的客户端装上 A 的代码,没有任何报错。前缀就是防这个的。

开发者 ID 发布应用后就不能再改

因为已经装了你应用的用户,是靠 app_key 找到它的。 改开发者 ID 等于给所有已发布应用换身份,他们会找不到已安装的那个。
一个应用都没发布过之前可以随便改,所以不用怕填错——真正要想清楚的时刻是 第一次发布,不是注册那一刻。

它现在做的事

开发者 ID 是技术命名空间,不是展示名。应用卡片上的「由 XX 开发」取的是 manifest.author → 账号显示名 → 邮箱前缀,和开发者 ID 无关。 普通用户看到的是应用名和图标,app_key 不露脸。

命名建议

建议别这样
开发者 IDsmartmob zhiqu-tech a1(太短没辨识度)、my-company-tech-dept(太长,每个 key 都要带着它)
应用名部分sourcing erp-sync test demo app1(三个月后你自己也认不出是哪个)

规则:3–20 个字符,小写字母开头,只能用小写字母、数字、连字符; 连字符不能连用也不能结尾。official gathersurf admin 这类保留词注册不了——它们会被用来冒充官方。

平台自营的应用不带前缀

你会在应用中心看到 purchase-order 这种没有前缀的 key,那是我们自己上架的。 有没有前缀,就是分辨"官方应用"和"第三方应用"最直接的标志——这也是保留词表存在的原因。

变量与接口命名

你的应用内部想怎么写都行,我们不检查。但下面这套是平台自己在用的, 照着来的好处是:你的代码和文档、示例工程、以及将来 AI 帮你改的代码,长得是一套。

一句话规则

位置风格
JS 变量 / 函数 / 对象键camelCasegoodsList fetchPrice()
JS 类 / 构造器PascalCasePriceTracker
常量(真正不变的)UPPER_SNAKEMAX_RETRY
IPC 通道名模块:动作goods:list collect:start
CSS class前缀-名字.x8t-card(前缀由平台生成,别自己编)
文件名kebab-caseprice-parser.js

IPC 通道名为什么是 模块:动作

goods:list      goods:star     goods:remove
collect:start   collect:stop   collect:state
win:create      win:remove     win:proxies

好处是按模块前缀就能一眼看全一个功能有哪些动作, 而 listGoods / starGoods / removeGoods 这种在长列表里是散的。 示例工程 main.js 里三十来个 handler 就是这么排的,翻一下就知道意思。

名字要说清"是什么",不是"有多短"

别写为什么
d tmp data2goods draftRow mergedGoods三个月后你自己也要重新读一遍才知道是什么
flag statusisRunning collectState布尔用 is/has/can 开头,一眼知道该拿它当真假用
timecreatedAt durationMs带单位durationMscreatedAtMspriceCents——单位写进名字,就不用记约定
pricepriceCents金额用整数分,永远不要用浮点
getUser()(其实会写库)fetchUser() / saveUser()get 让人以为没副作用
时间戳单位混用是会咬人的

我们自己在这上面栽过:同一列里后台写毫秒、客户端写秒。 表面正常,直到某个 expiresAt > now() 拿秒比毫秒——恒为假,授权瞬间失效, 而且不报任何错
所以:整个应用只用一个单位,而且把单位写进名字createdAtMsdurationMs)。
记约定会忘,看名字不会。

数据库表与字段

host.db 建表时的规范。这不是洁癖——SQL 有它自己的规矩, 按 JS 的习惯写会被语言本身咬。

为什么数据库不用驼峰

SQL 会把不加引号的标识符折叠大小写。你写 createdAt, 存进去可能变成 createdat;想保住大小写就得每一处都写引号, 少一处就是运行时报错。所以:

数据库列   snake_case     created_at_ms, goods_id, price_cents
    ↓ 取出来的那一刻转一次
JS 代码    camelCase      createdAt,  goodsId,  priceCents

转换点只放一个地方——就是你查询函数返回的那个对象。出了那个函数,全应用都是驼峰。

// ✓ 手写投影,一处转换
function listGoods() {
  // host.db 是同步的,不要加 await
  const rows = host.db.query(
    'SELECT id, title, price_cents, created_at_ms FROM goods ORDER BY id DESC LIMIT 200');
  return rows.map(r => ({
    id: r.id, title: r.title,
    priceCents: r.price_cents,      // ← 转换只发生在这里
    createdAtMs: r.created_at_ms,
  }));
}

// ✗ 直接把库里的行吐出去 —— 下划线会漏得整个应用到处都是
function listGoods() {
  return host.db.query('SELECT * FROM goods');   // 而且没有 LIMIT
}
为什么不写个自动转换的工具函数

SELECT * + 自动转驼峰看着省事,但它会把你没打算给界面看的列 一起送出去(密码哈希、第三方 token、内部备注)。 手写投影本质上是个白名单——多写几行,换的是"界面永远拿不到不该拿的字段"。

时间戳:你的库用毫秒,我们的云端用秒

你可能会注意到这个不一致,这里说清楚——不是两套标准,是同一条原则在写入的那一侧做零转换

谁在写单位列名
你的 host.db你的 JS 代码毫秒created_at_ms
我们的云端 PostgreSQLPython 服务created_at

Date.now() 给的就是毫秒,time.time() 给的就是秒。 各自直存,就没有转换,也就没有转错的机会。

而且这两个库不会碰面——应用拿不到我们云端的时间戳 (profiles.list() 只回 id / 窗口号 / 名字 / 分组 / 平台), 所以不存在"两种单位混进同一列"的风险。真正致命的从来是那个,不是选哪个单位。

字段规范

规则说明
表名用复数goods suppliers price_history
主键就叫 id外键叫 <表单数>_idsupplier_id
时间列一律 _at_ms 结尾created_at_ms updated_at_ms。别光叫 at,也别叫 last_login
时间戳存 Date.now()毫秒,用 INTEGER写入侧是 JS,Date.now() 本来就是毫秒——不转换就不会转错
金额用整数分price_cents INTEGER。浮点存钱迟早对不上账
布尔用 is_/has_ 开头is_starred has_stock(SQLite 里存 0/1)
不留缩写只有 id / url / ip / sku 这类行业通用的可以短。 pw(是明文还是哈希?)、grpdesc 都别用
敏感列名字要自己交代password_hash 而不是 pw——名字本身就是给下一个人的提醒

建表走 migrate(),不要直接 exec

建表和加列都放进 migrate()。它按版本号记账:跑过的版本下次启动时整条跳过, 所以你可以在 register() 里无条件调用,不用自己判断"表建过没有"。

host.db.migrate([
  {
    v: 1,
    name: 'create_goods',
    up: [`
      CREATE TABLE IF NOT EXISTS goods (
        id            INTEGER PRIMARY KEY AUTOINCREMENT,
        keyword       TEXT    NOT NULL,
        title         TEXT    NOT NULL,
        shop_name     TEXT,
        price_cents   INTEGER NOT NULL DEFAULT 0,   -- 整数分,不用浮点
        is_starred    INTEGER NOT NULL DEFAULT 0,   -- 布尔:0/1
        source_url    TEXT,
        created_at_ms INTEGER NOT NULL,             -- Date.now(),毫秒
        updated_at_ms INTEGER NOT NULL
      )`,
      // 查得多的列建索引 —— 上千行之后,没索引的模糊搜会肉眼可见地卡
      `CREATE INDEX IF NOT EXISTS idx_goods_keyword ON goods(keyword)`,
    ],
  },
  // 后来要加列,加一个新版本,不要改 v:1 的内容
  { v: 2, name: 'goods_add_note', up: [`ALTER TABLE goods ADD COLUMN note TEXT`] },
]);

返回 { from, to }——两个值相等表示这次什么都没跑。

三条硬规则

插入、更新、删除用 exec(sql, params);查询用 query(sql, params); 一批写操作包进 tx(fn)

建了表没人写,是最难发现的一类 bug

建表语句跑通了、代码不报错、界面也正常——只是那个数字一直是 0建完表立刻写一条测试数据读出来, 别等到功能都做完了才发现写入点根本没接上。

这一页是给「外部程序」看的 —— 用 Python / Node / Go / curl 或任何 RPA 工具, 从你自己的进程里驱动 GatherSurf,和「在客户端里开发应用」是两条完全不同的路。
用之前要先在客户端里打开端口设置 → 本地自动化 API,开启后才会监听 127.0.0.1:48090。它默认是关的 —— 不开的话所有请求都连不上, 而那个表现和"接口坏了"长得一模一样。

外部接口:这是什么 / 怎么开

除了「在客户端里开发应用」,还有第二条路:用你自己的语言、你自己的进程驱动 GatherSurf。 客户端在本机开了一个 HTTP 服务,你用 Python、Node、Go、curl——什么都行。

应用内接口 host.*外部接口 /gs/v1
适合谁想把工具交付给别人用有自己的技术栈,只想驱动浏览器
交付物能上架的应用卡片你自己的脚本
用户装了你应用的人,不用懂技术就是你自己
怎么调host.profiles.list()GET /gs/v1/profiles
鉴权manifest 权限声明X-GS-Token
前提用户装了你的应用客户端开着 + 设置里启用

怎么开启

  1. 客户端 →「设置」→「本地自动化 API」→ 打开
  2. 同一页能看到你的 token(就是账号级的 API token)
  3. 服务地址固定 http://127.0.0.1:48090,只监听本机
token 等于你的账号

它能建号、删号、开号、读 cookie。别写进会提交到仓库的文件里,用环境变量。
/health所有接口都必须带 token——这是为了挡住本机其他进程和恶意网页 (localhost fetch、DNS rebinding)直接驱动你的浏览器。

本地 API 不可能比客户端本身能力更大

客户端没登录时 token 为空,一律拒绝(不是放行)。建号同样撞云端的配额门禁—— 超出上限返回 403 而不是 502,错误信息直说原因。

30 秒跑通

不想自己写脚本?

有个现成的自测台可以下载,把这一页的接口逐个点着跑,先看清楚返回长什么样再动手 —— 见下一节自测台:下载与用法

先探活(这一个不要 token)

curl http://127.0.0.1:48090/gs/v1/health

Python

import os, requests

BASE  = "http://127.0.0.1:48090/gs/v1"
TOKEN = os.environ["GS_TOKEN"]          # ★ 别写死在代码里
H = {"X-GS-Token": TOKEN}

# 1. 建一个号
r = requests.post(f"{BASE}/profiles", headers=H, json={
    "name": "测试号",
    "group": "默认分组",
    "proxyLine": "socks5://user:[email protected]:1080",   # 可选
}).json()
pid = r["data"]["id"]

# 2. 开窗口 —— 返回 CDP 地址,接下来用 playwright/puppeteer 接管
r = requests.post(f"{BASE}/profiles/{pid}/launch", headers=H, json={}).json()
cdp = r["data"]["automation"]["cdpWs"]
print("CDP:", cdp)

# 3. 用 playwright 接上去
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
    browser = pw.chromium.connect_over_cdp(cdp)
    page = browser.contexts[0].new_page()
    page.goto("https://httpbin.org/ip")
    print(page.content()[:200])

# 4. 关掉
requests.post(f"{BASE}/profiles/{pid}/shutdown", headers=H, json={})

Node

const BASE = 'http://127.0.0.1:48090/gs/v1';
const H = { 'X-GS-Token': process.env.GS_TOKEN, 'Content-Type': 'application/json' };
const api = async (method, path, body) => {
  const r = await fetch(BASE + path, { method, headers: H,
    body: body ? JSON.stringify(body) : undefined });
  const j = await r.json();
  if (!j.ok) throw new Error(`${path} 失败: ${j.error?.message || r.status}`);
  return j.data;
};

const { id } = await api('POST', '/profiles', { name: '测试号' });
const { automation } = await api('POST', `/profiles/${id}/launch`, {});
console.log('CDP:', automation.cdpWs);

// 用 puppeteer 接管
const puppeteer = require('puppeteer-core');
const b = await puppeteer.connect({ browserWSEndpoint: automation.cdpWs });
const p = await b.newPage();
await p.goto('https://httpbin.org/ip');
console.log((await p.content()).slice(0, 200));

await api('POST', `/profiles/${id}/shutdown`, {});
响应格式统一
成功:{ "ok": true,  "data": { … } }
失败:{ "ok": false, "error": { "code": "forbidden", "message": "人话" } }

状态码是真实的:401 没登录/token 错、403 配额满或没权限、 404 找不到、409 状态冲突(比如换指纹时号还开着)、 502 云端出错。不会把配额满糊成 502——那样你无从判断该升级套餐还是该重试。

自测台:下载与用法

本地 API 自测台是一个外部工具——零依赖 Node,带界面,附全部源码。 它按第三方开发者的视角写成:只用这一页公开的接口,不碰任何内部约定。 两个用途:先跑一遍看接口通不通,以及当成可以直接抄的调用范例

它能做什么

能力说明
接口目录 这一页的全部接口都在里面,每条带一句人话说明和默认请求体
三档分级 read 只读 · write 会改数据 · danger 不可逆或会中断客户端。 「一键跑」永远不碰 danger 这一档——一个"测试工具"把用户的号删了, 是这类工具最容易犯也最不可原谅的错
危险档双闸 真删、清空回收站、重启/退出客户端只能单条点,页面确认之后进程里还要再拦一次
路径参数自动取 {id} 从对应的列表接口取第一条;取不到就跳过并说明原因, 而不是拿 undefined 拼出 /profiles/undefined 去撞一个 404 ——那种失败会被误读成"接口坏了"
端到端脚本 e2e.js 不看界面,按真实场景串起来跑:建号 → 开 → 导 cookie → 关 → 删, 中间该回 403/409 的地方断言它确实回了那个码 ——否则"修好了"和"没修"在报告上一模一样

下载

https://api.gathersurf.com/download/tool/gs-api-test  ·  查版本和校验值:/manifest

这个地址永远指向最新版(会 302 跳到就近的下载节点),所以不用记版本号。 下载后建议核一下 sha256 跟 manifest 对不对得上—— 这工具要你把 token 粘进去,值得多花那 5 秒:

shasum -a 256 gs-api-test.zip                # macOS / Linux
certutil -hashfile gs-api-test.zip SHA256    # Windows

怎么跑

需要 Node 18 以上(node -v 看一眼),解压后在目录里:

node gs-api-test.js      # 起界面,浏览器开 http://127.0.0.1:48099
node e2e.js              # 或者不看界面,直接跑一遍端到端场景

界面打开后把 token 粘进去就能点。★ token 只存在这个 Node 进程的内存里, 不写文件、不进日志——关掉进程就没了,所以每次起来都要重新粘。

它是一个本机进程,不是一个网页

本地 API 不发任何 CORS 头,所以浏览器页面拿不到响应——自测台必须是本机跑的 Node 进程,界面只是它自己吐出来的页面。
那道 CORS 门正是挡住恶意网页(localhost fetch、DNS rebinding)驱动你浏览器的东西。 任何"绕过 CORS"的做法都等于把那道门拆了。

源码可以直接抄

怎么带 token、怎么读返回信封、怎么区分「接口坏了」和「403 配额满」、 怎么在删东西之前先确认——这些在源码里都是明写的,比照着文档从零写快得多。

接口清单

字段名统一用驼峰

历史上混过两种风格(proxy_id / proxyLine 同时存在), 现在两种都收,但文档只教驼峰。老脚本不用改,新代码请用驼峰:
proxyId · kernelVersion · keepFingerprint · intervalMs · timeoutMs · basedOnIP · keepExtensions

服务自身

接口说明
GET /health探活。唯一不要 token 的
GET /account当前账号 + 配额(还能建几个号)
GET /statuspid / 运行时长 / 版本 / 开着几个号
GET /settings · PATCH /settings可改 kernelPath / headful / kernelVersion
POST /server/restart重启客户端,3~8 秒后同端口恢复
POST /server/shutdown关闭客户端
POST /server/kill-orphans清理没关干净的内核进程

环境(号)

接口参数 / 说明
GET /profiles全部号。每条含 lastOpenedAt(级时间戳,没开过是 null)和 locked(true = 超配额,开不了);外层还有 quota 汇总
POST /profilesname group tags remark archetype(默认 win11-desktop) seed proxyId|proxyLine basedOnIP kernelVersion platforms overrides
GET /profiles/{id}单个
PATCH /profiles/{id}增量改:只改传了的字段。proxyLine:"" = 清掉代理
DELETE /profiles/{id}真删(会先关窗)
POST /profiles/{id}/launch开窗。返回 automation.cdpWs / debugPort
超配额的号开不了(只有最新的 N 个能开,见 GET /profiles 里的 locked)。
这种情况回 403 + codeQUOTA_EXCEEDEDPLAN_EXPIRED(2026-08-05 起,原来错回 500)。
403 就别重试了 —— 重试一万次也一样;5xx 才是可以重试的。判据用 code,别去匹配中文(文案改一个字你的判断就失效)。
POST /profiles/batch/launch 里失败的那条现在也带 errorcode
POST /profiles/{id}/shutdown关窗。优雅关:会存盘标签页,下次开号恢复
POST /profiles/launch-new建号即开,一步到位
POST /profiles/{id}/copycount(≤50) keepFingerprint。默认换新指纹。不复制密码和 cookie
POST /profiles/{id}/clear-cachecookies(连 cookie 一起清) keepExtensions(默认 true)
GET /sessions当前开着的号 + 各自的 CDP 地址

指纹

接口说明
POST /fingerprint/preview只生成不建号,不占配额。先看再决定
GET /profiles/{id}/export导出指纹包(seed+archetype+overrides
POST /profiles/import用指纹包还原出同一指纹,可跨账号
POST /profiles/{id}/refresh-fingerprint换一套新指纹。号必须先关,否则 409。
别名 /randomize——同一个接口,老脚本里可能用的是这个
指纹是确定性生成的

(seed, archetype, overrides) 三件套确定一个指纹。所以「导出→在别的账号导入」 能还原出完全一样的指纹,而不需要传输一大坨指纹数据。

Cookie

接口说明
GET /profiles/{id}/cookies导出。号关着也能导——后台无头秒开读完即关。★ 前提是这个号至少开过一次:从没打开过的号本机还没有它的数据目录,会回 409 no_local_data(不是 5xx,别重试)
PUT /profiles/{id}/cookies导入。需号在运行中,否则 409。经 CDP 注入并落盘持久

批量

接口参数
POST /profiles/batch/launchids intervalMs(每个之间等一会儿) kernelVersion
POST /profiles/batch/shutdownidsall:true
PATCH /profiles/batchids + group/tags/remark 之一
POST /profiles/batch/deleteids

批量接口都返回逐条结果:{ total, succeeded, results:[{id, ok, error?}] }—— 部分失败不会让整批失败,你能准确知道是哪几个没成。

代理

接口说明
GET /proxies · GET /proxies/{id}列表 / 单个
POST /proxieslinesocks5://user:pass@host:porthost:port:user:passtags
PATCH /proxies/{id}line/type/tags。改了地址会自动重置检测状态
DELETE /proxies/{id}
POST /proxies/check检测还没保存的串:line timeoutMs。建号前先验一把
POST /proxies/{id}/check检测已存的,结果回写(出口 IP / 国家 / 状态)
GET /proxy-tags聚合出所有代理标签 + 各自有几条
PATCH /proxy-tags/{name}改名,作用于所有代理
DELETE /proxy-tags/{name}从所有代理上摘掉

分组 / 标签 / 回收站

接口说明
GET/POST /groups · PATCH/DELETE /groups/{id}分组增删改查。★ GET 里含默认分组(builtin:true,id __ungrouped__,name 是英文 Ungrouped)和未登记分组(orphan:true,id __orphan__*) —— 这两类改名/删除会回 400,遍历前先 filter(g => !g.builtin && !g.orphan)
GET /tags聚合出所有标签 + 各自有几个号
PATCH /tags/{name}改名,作用于所有号
DELETE /tags/{name}从所有号上摘掉这个标签
POST /profiles/{id}/trash · /restore软删 / 还原
GET /trash · POST /trash/empty回收站列表 / 清空(真删
/trashDELETE 是两回事

POST /profiles/{id}/trash软删(云端号还在,30 天后自动清); DELETE /profiles/{id}立刻真删。名字上容易混,脚本里别写错。

内核

接口说明
GET /kernels有哪些版本、装了没、要不要更新
GET /kernels/{v}/status这个版本是不是最新
POST /kernels/{v}/upgrade下载 / 自愈到最新构建
POST /kernels/{v}/default设为默认
DELETE /kernels/{v}删掉这个版本
POST /profiles/{id}/kernel指定某个号用哪个内核版本

常用配方

代理池自动体检,坏的打标签

proxies = requests.get(f"{BASE}/proxies", headers=H).json()["data"]["proxies"]
for px in proxies:
    r = requests.post(f"{BASE}/proxies/{px['id']}/check", headers=H,
                      json={"timeoutMs": 8000}).json()["data"]
    if not r["alive"]:
        # 打个标签,人工再看
        requests.patch(f"{BASE}/proxies/{px['id']}", headers=H,
                       json={"tags": px.get("tags", []) + ["失效"]})
    print(px["id"], "✓" if r["alive"] else "✗", r.get("ip", r.get("error")))

一个号一条代理,批量建

lines = open("proxies.txt").read().split()
for i, line in enumerate(lines, 1):
    chk = requests.post(f"{BASE}/proxies/check", headers=H, json={"line": line}).json()["data"]
    if not chk["alive"]:
        print(f"跳过第 {i} 条:{chk['error']}"); continue      # ★ 先验再建,别建一堆废号
    requests.post(f"{BASE}/profiles", headers=H, json={
        "name": f"号-{i}-{chk['country']}", "proxyLine": line,
        "basedOnIP": True,     # 按出口 IP 收口语言/时区,避免地理矛盾
    })

把一个号的指纹复制到另一个账号

# 账号 A 导出
fp = requests.get(f"{BASE}/profiles/{pid}/export", headers=H).json()["data"]
json.dump(fp, open("fp.json", "w"))

# 账号 B(换 token)导入 —— 还原出同一指纹
fp = json.load(open("fp.json"))
requests.post(f"{BASE}/profiles/import", headers=H2, json={"fingerprint": fp, "name": "还原号"})

批量开号跑任务,控制并发

ids = [p["id"] for p in profiles[:10]]
r = requests.post(f"{BASE}/profiles/batch/launch", headers=H,
                  json={"ids": ids, "intervalMs": 2000}).json()["data"]
print(f"{r['succeeded']}/{r['total']} 开成功")
for x in r["results"]:
    if not x["ok"]: print("  失败:", x["id"], x.get("error"))
    else:           print("  CDP:", x["automation"]["cdpWs"])

# … 跑你的任务 …

requests.post(f"{BASE}/profiles/batch/shutdown", headers=H, json={"ids": ids})
三个容易踩的

规范自查

⚙设置 →「规范自查」。自查不过就打不了包——不是为了卡你, 是这些问题在上传后同样会被拒,而原因本来在本地就知道。

查什么为什么
manifest 完整、version 是 x.y.z版本号不规范没法做更新判断
目录名和 manifest.key 一致不一致会装到错的地方
权限都在白名单里写错一个字母就静默拿不到能力
http.allow 写了就必须是合法域名/IP固定目标建议如实写;运行时暂不强制
没有 node_modules应用包不带依赖
有 README.md装它的人要知道这是什么
require('electron') / require('fs')绕过沙盒
没有裸 ipcMain.handle平台不知道那是你的接口
导出了 register没有它应用加载不了
界面是片段、CSS 带前缀、app.js 是 IIFE见上一节

自查只覆盖机器能判定的部分。权限是不是最小、错误处理够不够,要人看—— 自查通过不代表一定能上架。

静态自查之外:接口自检台

自查只覆盖机器能判定的形式问题。25 项全绿的应用,照样可能一个接口都调不通 —— 权限没申请、账号没登录、套餐没开通、方法名写错一个字母,这些运行时才暴露。

客户端自带一个叫「接口自检台」的应用(在「应用中心 →🛠 我的开发」里, key 是 gs-apicheck),把平台开放的每个 host 接口当场跑一遍, 告诉你哪些通、花了多久、返回长什么样。你的应用"点下去什么都不发生"时先跑它 —— 如果它也不通,问题不在你的代码。它的 main.js 同时是一份可以照抄的调用范例。

它把操作分成三档,不可逆的那一档永远不进批量(真删号、清空回收站); 建号、开窗这些写操作要显式勾选;弹框和 AI 只能人点。 你自己写自检工具时建议照这个分法 —— 一个"测试工具"把用户的号删了, 是这类工具最容易犯也最不可原谅的错。

三种发布

谁能用代码上传吗要审核吗别人下到的是
💻 本机只有这台设备不上传不用
👥 团队你授权的成员上传云端不用,立刻生效加密包
🌐 公开上架所有 GatherSurf 用户上传云端要,人工审加密包

发出去的代码是加密的

只要走云端分发(团队 / 公开上架),用户装到本地的包里, .js .html .css .json .md .txt磁盘上是密文。 成员打开应用目录看到的是二进制,复制走也跑不了。

环节形态为什么
你打包上传明文服务端要拆开跑校验器(路径穿越 / 反向依赖 / 权限越界),公开上架还要人工看代码
服务端保存明文原件复核、客服排查、你自己回看都要用
发布 / 审核通过加密一次每个版本一把随机密钥,存成分发件
用户下载密文运行时才在内存里解开,不落盘
开发和调试完全不受影响

加密只发生在「发布」那一刻。你的开发目录、本机发布、AI 开发、 npm run check 自查——全都是明文,跟以前一模一样。 判据是文件里的魔数,不是目录,所以两种包混着用也不会错。

唯一要改代码的地方:别自己读包里的文件

require()<script src> 会自动解密; 你用 fs.readFileSync 去读会拿到乱码——而报错是「意外的 token」之类, 指向你的数据,指不到「文件是密文」。

✗ const tpl = fs.readFileSync(path.join(__dirname,'tpl.html'),'utf8')
✓ const tpl = require('./tpl.js').html        ← 走 require
✓ const cfg = require('./config.json')        ← require 认 .json

要让用户改配置,用 host.storage,别让他去改包里的文件—— 那个文件他打不开,改了也会被下次更新覆盖。

说清楚它挡得住谁

挡得住的是团队成员和随手下载的人——他们看到二进制,到此为止。 挡不住有动机的逆向者:解密能力必须在客户端里,而客户端在对方机器上。 这是所有本地加密的共同上限,不是我们没做好。

所以:真正的核心算法请放在你自己的服务器上,应用只调接口。 加密是「防止随意查看和复制」,不是「无法破解」。

「开发版」和「已发布版」是两份东西

开发目录是源码(热重载,改一行就生效);发布出来的是快照。 改完源码不再发布一次的话,团队成员那边跑的还是旧的——卡片上会显示 「开发版 v0.4.0 / 已发布 v0.2.1 —— 存在未发布的修改」提醒你。

成员不能发布

发布是把代码推给整个团队,那是主账号的决定。

授权管理 —— 谁能装你的应用

发布到团队之后,在「我的开发 → ⚙ 设置 → 🔑 授权管理」里按对方的注册邮箱发授权。 被授权的账号不必是你的团队成员——可以是任何 GatherSurf 账号。

要点说明
授权对象是账号给一个账号授权,他和他团队的全部成员都能用。不用逐个成员加
对方必须是付费账号免费账号即使被授权也打不开——他的卡片上会显示「需付费套餐」, 升级后自动解锁,不用再找你
到期时间不限期,或指定日期。只管授权本身,和对方自己的套餐续费互不影响
撤销对方的应用中心里这个应用消失,已装的那份下次同步时自动卸载。 应用数据留在他本机,重新授权后还在
「授权了但打不开」的三个独立原因

三者都要满足,任何一个不满足表现都是"打不开",但排查方向完全不同:

  1. 你给的授权——过期了或被撤销 → 在授权管理里看
  2. 对方自己的套餐——到期或是免费版 → 他到「费用中心」续费,自动解锁
  3. 应用版本——被下架 → 重新发布一版

授权管理的名单里,免费账号会被标成「免费 · 用不了」,就是为了让你一眼看出是第 2 种。

上架审核

提交后进队列,我们人工看。审核期间你已经上架的老版本不受影响—— 用户照常用旧版,新版单独排队。

我们会看什么

结果怎么通知你

驳回一定带理由

不写理由的驳回等于让你重猜一遍,而能猜的方向有十几个(权限?出网?命名?体积?)。 所以后台不填理由是驳不掉的。改好之后重新提交即可,不用重走任何流程。

⚠ 避坑速查

这些都是真出过问题的,共同点是不报错—— 所以事后特别难查,而写的时候顺手避开几乎不花时间。

一句话总纲:看起来在工作 ≠ 在工作

下面每一条都是这句话的一个变体。判断标准只有一个 —— 你有没有拿"预期值"和"实际值"比对过。 没有比对,那一步就没有被验证,不管它打印了多少个"成功"。

「沉默 ≠ 正常」

代码没报错不等于它在工作。最典型的症状是某个数字一直是 0: 建了表没人往里写、事件从来没发过、参数收了没往下传。 看到「一直是 0」,先怀疑写入点存不存在,别先怀疑读取逻辑。

encodeURIComponent 会把 / 编成 %2F

用在"本来就含斜杠"的路径片段上,请求会打到一个不存在的地址:
'.../repos/' + encodeURIComponent('torvalds/linux').../repos/torvalds%2Flinux404
而 404 通常会被理解成"资源不存在",于是界面上写着「仓库不存在:torvalds/linux」—— 那个仓库有 24 万星。要编码的是参数值,不是整段路径。

★ 出网失败时把实际请求的 URL 带进错误信息,这类问题一眼就能看出来。

把所有非 200 归成一个原因

404(地址不对)、403(限流或没权限)、5xx(对方挂了)—— 用户的处理方式完全不同。归成一句"请求失败",他只能重试,而重试大概率还是这个结果。

长任务不推进度

invoke 是一次请求一次返回。循环里每轮都要花时间(建号、开窗口、请求)时, 跑完才返回意味着界面几十秒完全静止——用户会以为卡死,然后重复点击或者关掉重来。
每完成一轮 host.ipc.send 一次,界面 gsApp.on 追加一行。

批量操作整个包一个 try/catch

20 个里第 7 个失败,用户只看到"批量失败",而实际上已经成功了 6 个—— 他不知道成了几个、哪几个没成。每一轮各自 try,失败记下来继续, 最后按原因归类计数。

参数收了但没往下传

function f(a, b) { g(a); }——b 收了没用。 语法没错、类型没错、测试也不一定覆盖,但功能就是不生效。

取了一个不存在的键

info.get('manifest') 而对方只返回了 name—— 拿到 undefined,走进默认分支,一切「正常」,只是结果不对。 两边各自都是对的,接口对不上而已。

JSON 双重编码

数据库驱动已经会自动序列化,你又 JSON.stringify 了一次—— 存进去的是「内容恰好是 JSON 的字符串」。.includes() 碰巧还能用(子串匹配), .map() / .length 就全错。

金额用浮点

永远用整数「分」。0.1 + 0.2 !== 0.3 在对账时是灾难。

时间戳秒和毫秒混用

定一个标准并且写进注释。混了的表现是「时间显示成公元 58000 年」—— 这个还算好发现的,更糟的是比较大小时静默出错。

先看一眼再断言「好了」

功能全对但版式是坏的,只有截图才看得出来。接口返回 ok:true 不代表用户看到的是对的。