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)
- 客户端左边 →「✨ AI 开发」→ 右上角「+ 新应用」
- 填个中文名(比如「代理测速台」),标识只填后半截,前缀是自动加的
- 用大白话说你要什么:
「做一个代理测速台:列出我所有代理,点全部测速, 显示延迟和是否可用,能按延迟排序,坏的标红」 - 它会先问你界面用哪套框架——点一下选就行(不确定就选推荐的那个)
- 生成完点「▶ 预览运行」——直接跑起来看效果
- 不满意就接着说:「表格加一列地区」「把测速改成并发 5 个」
- 满意了点「📦 放进我的开发」,它就成了一个正式应用,可以发布
路 B:自己写
- 「应用中心」→「🛠 我的开发」→「+ 新建应用」
- 选「📘 示例工程」——那是一个完整能跑的应用,每个接口都有可点的按钮
- 点「打开」跑一遍,看哪个接口是你要的
- 「📂 打开开发目录」,用你惯用的编辑器改
main.js - 存盘,回到客户端——会自动重载,不用重启
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 读的:
全部接口签名、硬约束、常见错误、怎么改怎么发布,都在里面。
三步
- 新建应用时选「📘 示例工程」,得到一份能跑的完整代码
- 点「📂 打开开发目录」,把整个目录(含
AGENTS.md)交给你的 AI - 直接说你要什么——「把 1688 换成淘宝」「加一个自动比价提醒」「导出改成 Excel」
代码里看不到的东西有一半:哪些全局被运行时遮蔽了、样式前缀怎么推导、
自查会拦什么、host.db 为什么必须加 LIMIT、
白名单现在是记录还是拦截。AI 靠猜会写出语法完全正确但打不了包的代码。
可以直接复制的开场白
这是 GatherSurf 客户端的一个应用工程。
先读 AGENTS.md,它写明了全部接口、硬约束和常见错误。
读完后按我的需求改,注意:
- 不要 require('electron') / require('fs')
- CSS 每条选择器都要带工程里已有的前缀
- ui/app.js 必须保持 IIFE
- 改表结构只能在 ensureSchema() 里往后加版本,不能改已有的
我的需求是:______
点⚙设置里的规范自查。它检查 25 项机器能判定的东西——
AI 最容易漏的是样式前缀和 IIFE,自查会当场指出来。
自查过了再点「打开」实际跑一遍:自查只管形式,不管你的逻辑对不对。
目录结构
你的应用/
├─ manifest.json 你是谁、要什么权限、入口在哪
├─ main.js 后端逻辑(主进程)
├─ README.md 必须有,自查会检查
└─ ui/
├─ index.html 界面片段(不是完整文档)
├─ style.css 样式(每条选择器都要带前缀)
└─ app.js 界面逻辑(必须是 IIFE)
应用包不带依赖。你只能用 Node 内置的纯计算模块(path、crypto、url 这类),
不能 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" }
}
它同时是三样东西:本地目录名、上架后的 OSS 路径、IPC 路由键。 前缀(你的账号命名空间)是强制的——不带的话,两个客户建了同名应用会互相覆盖, 而且是静默覆盖:B 客户装上 A 客户的代码。
权限一览
| 权限 | 给你什么 | 备注 |
|---|---|---|
storage | host.storage | 几乎都要 |
db | host.db | 要付费档位 |
http | host.http | 白名单选填;固定域名建议写 |
profiles:read | host.profiles 只读 | |
profiles:control | 加上开/关窗口 | |
profiles:write | 建号/改号/删号/配代理 | 能删号,不可逆 |
automation | host.automation | 审核重点看 |
files:pick | host.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.storagestorage同步调用 | dir()list()read(n)readJson(n, d)remove(n)write(n, t)writeJson(n, o) |
host.dbdb同步调用 | close()exec(sql, params)migrate(list)path(值)query(sql, params, o)tx(fn) |
host.httphttp都要 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.automationautomation都要 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.filesfiles: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
什么时候别用要查询、要统计、要上千条 —— 用 db。storage 只能整个读出来在 JS 里过滤,条数一多就卡。
全部是同步的,不要加 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;想删掉一个文件请用 remove。
write(name, null) 不是删除的写法,会抛 TypeError。
给 ../../别人的文件 会被拒。路径由平台拼,
所以「越界」在架构上不可能发生,不依赖代码审查去发现。
只要你需要查询、排序、统计、分页。JSON 一旦上千条,每次都要全量读进内存 再自己 filter,而 SQL 走索引是微秒级的。这条线比想象中来得早。
数据库 db 要付费档位
什么时候别用只是记住"用户上次选了哪个" —— 那用 storage,不值得为一个字段建库。
一次查询有多慢,整个客户端就卡多久——所有窗口操作全停。
实测五万行的库:聚合报表 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 | 拒绝写入 |
| 单个字符串 / blob | 64 MB | 报错(大文件别塞库,用 storage 存路径) |
悄悄截断会让你的报表算出一个看起来正常、实际是错的合计—— 那比报错危险得多。要全量就分页,要统计就交给 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
localhost 都能访问,不需要预先申请。什么时候别用想抓一个网页上渲染出来的内容 —— 那要用 automation 打开页面取,http 拿到的是原始 HTML,很多站点的内容是 JS 渲染出来的。
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 时:
- Location 指向的域名在你的
http.allow里 → 自动跟随,只跟一跳(防止跳转成环) - 指向白名单之外 → 不跟随,抛错并告诉你是哪个域名
- 你没写白名单 → 一律不跟随(因为无从判断跳到哪里算安全)
跟到已经授权的域名不增加任何暴露面——应用本来就能直接请求它。 真正的风险是跳出白名单:你信任的域名回一个 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
什么时候别用拿不到代理密码和平台账号密码,指纹也是云端生成的,你不参与。
// 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 起):
builtin: true—— 默认分组,id固定__ungrouped__。 它不是数据库里的一行,是profile.group的默认值"Ungrouped"。name拿到的就是英文"Ungrouped"—— 要显示给人看请自己换成「默认分组」。orphan: true—— 未登记分组,id形如__orphan__xxx。 号上写着这个组名、但从没建过这个组(用接口建号时直接给group字段就会这样)。- 两个标记都没有 = 真分组,
id是数据库 id。
对这两类调改名/删除会被拒(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 加进去的代理留在账号的代理库里,接口这边没有删除入口——
写测试或者试用时注意,别在客户账号里堆垃圾。
control 和 write 是两个权限
control 是「操作已有的号」,write 是「改变账号里有什么」——
建号占配额、删号不可逆、改代理换出口 IP。合成一个的话,一个只想开关窗口的应用
会被迫拿到删号的能力,而用户装的时候看到的权限说明也就失去了区分度。
指纹是云端生成的。你只说要什么平台、哪个分组、用哪条代理,剩下的交给平台—— 这样同一个账号下的指纹策略是统一的,也不会因为某个应用生成得不对而露馅。
建号会撞云端的三层门禁(未登录 / 无权限 / 超出上限)。 把它包装成一句「创建失败」,用户就完全不知道是要升级套餐还是先删几个号。
代理用「一行式」串,别自己拆
await profiles.addProxy({ name: '香港节点', line: 'socks5://user:[email protected]:1080' });
云端负责解析成 type/host/port/user/pass。你自己拆的话,拆法一变两边就不一致—— 而不一致的表现是「代理配了不生效」,查起来很费劲。
代理密码、平台账号密码不出这一层——不是靠前端隐藏,是接口里根本不返回。
页面操作 automation 审核重点看
什么时候别用多步骤的活儿优先用
runSteps 跑一条 RPA 流程 —— 比自己拼 evaluate + realClick 稳得多,代码也短。第一个参数都是窗口(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.名字 取 ——
按下标取的写法,中间插一步就全串位了。
evaluate 要自己写 return
code 是语句块(被包在 async 函数里执行),漏了 return
就是 value: undefined,而这一步照样 ok: true ——
结果凭空消失且不报错。
{ op:'evaluate', code:'return document.querySelectorAll(".result").length' }
★ 注意和 automation.evaluate(id, expr) 相反:那个收的是表达式,
不要写 return。两个名字一样、约定相反,是最容易搞混的一处。
op 字段是必须的——写成 type 或 action
会得到「未知 RPA 步骤:undefined」。
不判 .ok 的话,流程里每一步都失败你也只会看到"执行成功",
因为 runSteps 是把错误放在返回值里的。
evaluate 的返回值必须能 JSON 序列化
返回 DOM 节点会拿到一个空对象 {},而且不报错。
要元素的信息就在表达式里取出来:'document.querySelector("h1").textContent'。
file:// 会被拒
否则一个只有 automation 权限的应用就能用浏览器去读磁盘上任何文件——
数据库沙盒被整个绕过(它防的是 SQL,防不了浏览器),.gs-auth 之类的凭证也一样。
沙盒的强度 = 同时授出的所有能力里最弱的那个。
每个标签页都是一个渲染进程。任务一轮轮跑而不关,内存会一直涨。
el.click()
很多站点不认合成事件。数量输入框尤其典型:用 value= 赋值不会进 React 的状态,
页面显示变了、提交的还是旧值——而且不报错。
实操:开窗口 → 上 1688 → 搜商品 → 落库
示例应用:main.js 第 5.5 节 · 界面「★ 实操」那一节前面几节讲单个接口怎么用,这一节是它们串起来长什么样——真实业务里你要写的就是这种流程。
七步
- 选窗口——没指定就用第一个。真实应用里应该让用户选或按分组轮换, 闷头用第一个在多账号场景下会一直用同一个号
- 确保它开着——已经开着就跳过,重复开会走一遍完整启动,白等好几秒
- 直接拼搜索 URL 开标签页,不去首页点搜索框——能用 URL 表达的意图就别用点击表达, 少一步就少一个会坏的地方
- 轮询等商品渲染出来(见下)
evaluate在页面里抓数据- 截图存证
- 一个事务批量写进数据库
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 序列化
})()`);
返回 DOM 节点会拿到一个空对象 {}——不报错,只是什么都没有。
这是新手最常撞的一个坑。
批量写库用一个事务
db.tx(() => {
for (const it of items) db.exec('INSERT INTO offers(...) VALUES(?,?,?)', [...]);
});
十条分十次提交 = 磁盘同步十次,慢一个数量级。
示例里每一步都记进 steps 并实时推给界面。
出问题时用户看到的是「卡在等待商品加载」,而不是笼统一句「失败了」——
前者他自己就能判断是要登录还是网络慢。
开窗口、搜索、读列表、截图——不点加购、不下单。那些动作有真实后果, 示例代码不该替你做。你要做的话,务必先让用户确认,并且把「做了什么」记下来。
文件选择 files:pick
什么时候别用弹的是系统对话框,会阻塞。别放在批量循环里 —— 跑到第三个弹一次,用户以为程序卡住了。
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-infogs-note-warngs-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 的第一轮只问不写: 给出四个可点的选项(平台组件 / Bootstrap 5 / Milligram / Daft),选完才开始生成。
为什么是问而不是替你选:框架是选完就改不动的决定 ——
界面全部代码都建立在它上面,换一个等于整个 ui/ 重写。
一次点击换一次重写,值。
已经知道要用什么就直接说("用 Bootstrap 做一个…"),它不会再问。
在 manifest.json 里声明一句就能用,应用不带任何框架文件:
{
"key": "abc-demo",
"ui": { "framework": "bootstrap5" },
...
}
| framework | 说明 | 体积 |
|---|---|---|
bootstrap5 | Bootstrap 5.3.3,含 JS 组件(模态框 / 下拉 / 折叠 / 标签页 / 轮播 / 提示气泡) | CSS 258KB + JS 81KB |
milligram | 极简,无类名 —— 写语义化 HTML 就有样式。纯 CSS | 22KB |
daft | 无类名,现代风格(接近 shadcn/ui 的质感)。纯 CSS | 86KB |
框架的样式挂在一个作用域容器下,容器由平台自动包,你只管写正常的页面:
<!-- 声明了 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>
这意味着现有页面可以直接搬过来,也意味着搬走时不用改回去。
前缀 = key 每一段的首字母:abc-price-helper → aph。
同一个开发者下重名是可能的(abc-batch-run 和 abc-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
- 不能用 CDN。页面的 CSP 是
default-src 'self', 外部地址一律加载不了。必须把框架文件下载下来,内容并进ui/style.css—— 界面只加载这一个 CSS 文件(还有index.html和app.js), 多放几个文件不会被读取。 - 框架的 JS 组件(下拉、模态框)同样不能走 CDN,
要并进
ui/app.js,而那个文件必须是 IIFE。 - 体积算进应用包,上限 20MB。Bootstrap 压缩后约 230KB,不成问题。
应用区是 position:fixed 的包含块,所以应用里任何"铺满屏幕"的东西
(Bootstrap 的模态框、你自己写的遮罩层)最远只铺到应用区边界 ——
左侧导航和顶栏始终可点。
这不是为了好看:遮罩一旦因为任何原因没关掉(保存失败、脚本报错、 点到没绑事件的地方),用户如果连侧边栏都点不到,就没有退路, 只能关掉整个客户端。所以这一条由平台保证,你不用做什么。
顺带:Bootstrap 那两层挂在 body 上的 backdrop 被平台隐藏了,
灰底改由 .modal 自己提供 —— 视觉一样,范围受控。
包装是一次性成本,但框架的样式和客户端本身的视觉语言是两套 ——
用户会明显看出"这一块是另外贴上去的"。
只是要按钮、表格、表单的话,上面那套 gs- 组件跟随客户端主题,
不用包装也不占体积。真正需要框架的场景是它有 gs-* 没有的东西
(栅格系统、模态框、日期选择器这些)。
前缀由 key 推导(每段首字母)。CSS 标识符不能以数字开头,
所以数字开头的会自动补一个 a:8d7dk-example → a8e。
自查会告诉你该用哪个前缀。
用 iframe 就享受不到宿主的 CSS 变量(主题、配色),还得额外做通信桥。 选了同文档,就得靠前缀约定——所以自查逐条检查它。
3. app.js 必须是 IIFE
(function () {
'use strict';
// 你的代码
})();
不包一层的话,你的 let out = ... 会挂到 window 上,和宿主或别的应用撞名——
而撞名的表现是「另一个应用的变量莫名其妙变了」,最难查的那种。
window.prompt
alert 和 confirm 可以用,唯独 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 的代码,没有任何报错。前缀就是防这个的。
因为已经装了你应用的用户,是靠 app_key 找到它的。
改开发者 ID 等于给所有已发布应用换身份,他们会找不到已安装的那个。
一个应用都没发布过之前可以随便改,所以不用怕填错——真正要想清楚的时刻是
第一次发布,不是注册那一刻。
开发者 ID 是技术命名空间,不是展示名。应用卡片上的「由 XX 开发」取的是
manifest.author → 账号显示名 → 邮箱前缀,和开发者 ID 无关。
普通用户看到的是应用名和图标,app_key 不露脸。
命名建议
| 建议 | 别这样 | |
|---|---|---|
| 开发者 ID | smartmob zhiqu-tech |
a1(太短没辨识度)、my-company-tech-dept(太长,每个 key 都要带着它) |
| 应用名部分 | sourcing erp-sync |
test demo app1(三个月后你自己也认不出是哪个) |
规则:3–20 个字符,小写字母开头,只能用小写字母、数字、连字符;
连字符不能连用也不能结尾。official gathersurf admin
这类保留词注册不了——它们会被用来冒充官方。
你会在应用中心看到 purchase-order 这种没有前缀的 key,那是我们自己上架的。
有没有前缀,就是分辨"官方应用"和"第三方应用"最直接的标志——这也是保留词表存在的原因。
变量与接口命名
你的应用内部想怎么写都行,我们不检查。但下面这套是平台自己在用的, 照着来的好处是:你的代码和文档、示例工程、以及将来 AI 帮你改的代码,长得是一套。
一句话规则
| 位置 | 风格 | 例 |
|---|---|---|
| JS 变量 / 函数 / 对象键 | camelCase | goodsList fetchPrice() |
| JS 类 / 构造器 | PascalCase | PriceTracker |
| 常量(真正不变的) | UPPER_SNAKE | MAX_RETRY |
| IPC 通道名 | 模块:动作 | goods:list collect:start |
| CSS class | 前缀-名字 | .x8t-card(前缀由平台生成,别自己编) |
| 文件名 | kebab-case | price-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 data2 | goods draftRow mergedGoods | 三个月后你自己也要重新读一遍才知道是什么 |
flag status | isRunning collectState | 布尔用 is/has/can 开头,一眼知道该拿它当真假用 |
time | createdAt durationMs | 带单位。durationMs、createdAtMs、priceCents——单位写进名字,就不用记约定 |
price | priceCents | 金额用整数分,永远不要用浮点 |
getUser()(其实会写库) | fetchUser() / saveUser() | get 让人以为没副作用 |
我们自己在这上面栽过:同一列里后台写毫秒、客户端写秒。
表面正常,直到某个 expiresAt > now() 拿秒比毫秒——恒为假,授权瞬间失效,
而且不报任何错。
所以:整个应用只用一个单位,而且把单位写进名字(createdAtMs、durationMs)。
记约定会忘,看名字不会。
数据库表与字段
用 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 |
| 我们的云端 PostgreSQL | Python 服务 | 秒 | created_at |
Date.now() 给的就是毫秒,time.time() 给的就是秒。
各自直存,就没有转换,也就没有转错的机会。
而且这两个库不会碰面——应用拿不到我们云端的时间戳
(profiles.list() 只回 id / 窗口号 / 名字 / 分组 / 平台),
所以不存在"两种单位混进同一列"的风险。真正致命的从来是那个,不是选哪个单位。
字段规范
| 规则 | 说明 |
|---|---|
| 表名用复数 | goods suppliers price_history |
主键就叫 id | 外键叫 <表单数>_id:supplier_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(是明文还是哈希?)、grp、desc 都别用 |
| 敏感列名字要自己交代 | 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 }——两个值相等表示这次什么都没跑。
- 一个
up元素只写一句 SQL。多句写在一个字符串里,只有第一句会执行, 其余静默丢弃,而版本号照记——那条迁移永远不会重跑,缺的表和索引永远不存在。 平台会在动库之前拦下这种写法并报错。 - 表名列名必须是写死的字面量。带时间戳或随机数的表名配上固定的
v, 第二次启动就是"新表名 + 老版本号",整条迁移被跳过,接下来每条 SQL 都no such table。 - 版本号用过就不能改内容。改了平台会直接抛错并告诉你该用哪个新版本号—— 因为不抛的话,你写的这些 SQL 一句都不会执行,而调用方看不出任何异常。
插入、更新、删除用 exec(sql, params);查询用 query(sql, params);
一批写操作包进 tx(fn)。
建表语句跑通了、代码不报错、界面也正常——只是那个数字一直是 0。 建完表立刻写一条测试数据读出来, 别等到功能都做完了才发现写入点根本没接上。
⚠ 用之前要先在客户端里打开端口:设置 → 本地自动化 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 |
| 前提 | 用户装了你的应用 | 客户端开着 + 设置里启用 |
怎么开启
- 客户端 →「设置」→「本地自动化 API」→ 打开
- 同一页能看到你的 token(就是账号级的 API token)
- 服务地址固定
http://127.0.0.1:48090,只监听本机
它能建号、删号、开号、读 cookie。别写进会提交到仓库的文件里,用环境变量。
除 /health 外所有接口都必须带 token——这是为了挡住本机其他进程和恶意网页
(localhost fetch、DNS rebinding)直接驱动你的浏览器。
客户端没登录时 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 /status | pid / 运行时长 / 版本 / 开着几个号 |
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 /profiles | name 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 + code 为 QUOTA_EXCEEDED 或 PLAN_EXPIRED(2026-08-05 起,原来错回 500)。★ 403 就别重试了 —— 重试一万次也一样;5xx 才是可以重试的。判据用 code,别去匹配中文(文案改一个字你的判断就失效)。★ POST /profiles/batch/launch 里失败的那条现在也带 error 和 code。 |
POST /profiles/{id}/shutdown | 关窗。优雅关:会存盘标签页,下次开号恢复 |
POST /profiles/launch-new | 建号即开,一步到位 |
POST /profiles/{id}/copy | count(≤50) keepFingerprint。默认换新指纹。不复制密码和 cookie |
POST /profiles/{id}/clear-cache | cookies(连 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/launch | ids intervalMs(每个之间等一会儿) kernelVersion |
POST /profiles/batch/shutdown | ids 或 all:true |
PATCH /profiles/batch | ids + group/tags/remark 之一 |
POST /profiles/batch/delete | ids |
批量接口都返回逐条结果:{ total, succeeded, results:[{id, ok, error?}] }——
部分失败不会让整批失败,你能准确知道是哪几个没成。
代理
| 接口 | 说明 |
|---|---|
GET /proxies · GET /proxies/{id} | 列表 / 单个 |
POST /proxies | line(socks5://user:pass@host:port 或 host:port:user:pass)tags |
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 | 回收站列表 / 清空(真删) |
/trash 和 DELETE 是两回事
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})
- 换指纹前必须先关号——否则 409。新指纹要下次开号才生效,而本地数据还是旧的
- 导入 cookie 需号在运行中——导出不用(关着也能导),导入要
- 批量接口不会因为一个失败就整批停——一定要看
results里每一条, 别只看succeeded的数字
规范自查
⚙设置 →「规范自查」。自查不过就打不了包——不是为了卡你, 是这些问题在上传后同样会被拒,而原因本来在本地就知道。
| 查什么 | 为什么 |
|---|---|
| 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 账号。
| 要点 | 说明 |
|---|---|
| 授权对象是账号 | 给一个账号授权,他和他团队的全部成员都能用。不用逐个成员加 |
| 对方必须是付费账号 | 免费账号即使被授权也打不开——他的卡片上会显示「需付费套餐」, 升级后自动解锁,不用再找你 |
| 到期时间 | 不限期,或指定日期。只管授权本身,和对方自己的套餐续费互不影响 |
| 撤销 | 对方的应用中心里这个应用消失,已装的那份下次同步时自动卸载。 应用数据留在他本机,重新授权后还在 |
三者都要满足,任何一个不满足表现都是"打不开",但排查方向完全不同:
- 你给的授权——过期了或被撤销 → 在授权管理里看
- 对方自己的套餐——到期或是免费版 → 他到「费用中心」续费,自动解锁
- 应用版本——被下架 → 重新发布一版
授权管理的名单里,免费账号会被标成「免费 · 用不了」,就是为了让你一眼看出是第 2 种。
上架审核
提交后进队列,我们人工看。审核期间你已经上架的老版本不受影响—— 用户照常用旧版,新版单独排队。
我们会看什么
- 权限是不是最小——要了
automation但其实用不上? - 出网白名单合不合理——有没有写成通配?要往哪发数据?
- 代码本身——审核界面能直接看包里每个文件,声明和实现不一致会被发现
结果怎么通知你
- 客户端里主动弹提示,侧栏「应用中心」上打角标
- 卡片上留一行状态;点「详情」能看到完整审核记录——提交过几次、每次的驳回理由
- 提交时可以留联系邮箱,审核人会把结果发到这个邮箱
不写理由的驳回等于让你重猜一遍,而能猜的方向有十几个(权限?出网?命名?体积?)。 所以后台不填理由是驳不掉的。改好之后重新提交即可,不用重走任何流程。
⚠ 避坑速查
这些都是真出过问题的,共同点是不报错—— 所以事后特别难查,而写的时候顺手避开几乎不花时间。
下面每一条都是这句话的一个变体。判断标准只有一个 —— 你有没有拿"预期值"和"实际值"比对过。 没有比对,那一步就没有被验证,不管它打印了多少个"成功"。
代码没报错不等于它在工作。最典型的症状是某个数字一直是 0: 建了表没人往里写、事件从来没发过、参数收了没往下传。 看到「一直是 0」,先怀疑写入点存不存在,别先怀疑读取逻辑。
encodeURIComponent 会把 / 编成 %2F
用在"本来就含斜杠"的路径片段上,请求会打到一个不存在的地址:
'.../repos/' + encodeURIComponent('torvalds/linux') → .../repos/torvalds%2Flinux → 404。
而 404 通常会被理解成"资源不存在",于是界面上写着「仓库不存在:torvalds/linux」——
那个仓库有 24 万星。要编码的是参数值,不是整段路径。
★ 出网失败时把实际请求的 URL 带进错误信息,这类问题一眼就能看出来。
404(地址不对)、403(限流或没权限)、5xx(对方挂了)—— 用户的处理方式完全不同。归成一句"请求失败",他只能重试,而重试大概率还是这个结果。
invoke 是一次请求一次返回。循环里每轮都要花时间(建号、开窗口、请求)时,
跑完才返回意味着界面几十秒完全静止——用户会以为卡死,然后重复点击或者关掉重来。
每完成一轮 host.ipc.send 一次,界面 gsApp.on 追加一行。
20 个里第 7 个失败,用户只看到"批量失败",而实际上已经成功了 6 个—— 他不知道成了几个、哪几个没成。每一轮各自 try,失败记下来继续, 最后按原因归类计数。
function f(a, b) { g(a); }——b 收了没用。
语法没错、类型没错、测试也不一定覆盖,但功能就是不生效。
info.get('manifest') 而对方只返回了 name——
拿到 undefined,走进默认分支,一切「正常」,只是结果不对。
两边各自都是对的,接口对不上而已。
数据库驱动已经会自动序列化,你又 JSON.stringify 了一次——
存进去的是「内容恰好是 JSON 的字符串」。.includes() 碰巧还能用(子串匹配),
.map() / .length 就全错。
永远用整数「分」。0.1 + 0.2 !== 0.3 在对账时是灾难。
定一个标准并且写进注释。混了的表现是「时间显示成公元 58000 年」—— 这个还算好发现的,更糟的是比较大小时静默出错。
功能全对但版式是坏的,只有截图才看得出来。接口返回 ok:true
不代表用户看到的是对的。