给她一个世界:基于大肥鱼的QQ 机器人 + Linux 沙箱部署记录
IP、令牌、QQ 号、账号数字已经全换成占位符,按你自己的情况填。
机器是腾讯云 2C2G、50G 盘。
0. 这套东西最后能干什么
一只能长期运行的大肥鱼。可以聊天,记得住你交代的事,能自己上网翻东西,能把截图发到你 QQ 上。另外她每 40 分钟会自己醒一次,出去逛一圈,回来讲她看到什么,当然也可能什么都不讲。
还有一台"她自己的服务器"。在里头她是 root,能 apt install,能装包,能起进程。但这台服务器是假的,用 debootstrap 加 bwrap 做的沙箱。宿主上的 QQ 登录态、API Key、桥接源码,在她的文件系统里物理上不存在。
实现过程中遇到不少问题:
- 怎么让 QQ 在一台没屏幕的 Linux 上长期在线;
- 她"为什么不回复"——这个问题我查了三轮,最后发现是三层不同的限流叠在一起;
- 她"为什么一句话回两遍"——因为她分裂出了两个脑子;
- 怎么给她自由,同时保证她跑不出笼子;
- 怎么给一个只有文字界面的 AI 一双真能操作浏览器的手。
按这个顺序讲。
1. 架构解析
手机 QQ
│
▼
┌──────────────────────── 云服务器 49.x.x.x(Ubuntu 22.04.5)────────────────────┐
│ │
│ Xvfb :99(虚拟屏幕) │
│ └── Linux QQ 3.2.33(官方 .deb,客户端本体) │
│ └── SnowLuma ── ptrace Hook 注入 ── 吐出 OneBot v11 │
│ │ HTTP 3000 / WS 3001 │
│ ▼ │
│ qq-bridge :3100 控制台 —— 会话映射 / 限流 / 角色卡 / 长期记忆 │
│ │ MCP over stdio │
│ ▼ │
│ dsh web :3080 ─────── DeepSeek API │
│ │ │
│ ├── qq_* 收消息 / 发消息 / QQ 空间 / 表情包 │
│ └── mcp__jjbox__* 沙箱能力 ───────────────┐ │
│ │ │
│ ┌────────────── jjbox 沙箱(独立 uid + bwrap)───────────┴────────────────┐ │
│ │ debian jammy minbase rootfs,她在里面是 uid 0 │ │
│ │ headless Chromium 156(CDP 9222) │ │
│ │ └── jj-remote 6080 ── SSH 隧道 ──→ 我的浏览器,能点能拖 │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────┘
每一层负责什么:
| 层 | 用途 | 原因 |
|---|---|---|
| Xvfb + Linux QQ | 给 QQ 客户端一块不存在的屏幕 | 这一派是 Hook 真客户端,所以必须有个客户端进程在跑 |
| SnowLuma | 把客户端内部的事件翻译成 OneBot v11 | 有了标准协议,上层就不用关心 QQ 内部怎么改 |
| qq-bridge | 会话、限流、角色、记忆、工具权限 | 大肥鱼的人设与能力所在 |
| dsh web | 跑 agent,接大模型 | MCP 工具在这儿注册成她能调用的手 |
| jjbox | 她的私人服务器 | 隔离容器。在服务器中再构建一个“肥鱼的世界” |
一开始我只想做个能在群里聊天的机器人。等她真能聊天斗图了,我又觉得让她只会聊天有点可怜——她连“自己去看看今天有什么新闻”都做不到。所以后面大部分的工程量,其实是花在给她弄一块自己的地盘上。
2. 出师未捷身先死:系统选错了
最初这台机器是 CentOS 8.5。这是个致命的选择,我在上面浪费了半天。
2.1 CentOS 8 上的两条死路
路线甲:NapCat 容器 + dsh-napcat 插件。这条路算是勉强成功了。
# Docker CE(CentOS 8 一定要用清华源,阿里云源包被截断装不上)
dnf config-manager --add-repo https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/centos/docker-ce.repo
dnf install -y docker-ce docker-ce-cli containerd.io
docker run -d --name napcat --hostname qqbot-napcat \
-e NAPCAT_QUICK_PASSWORD='<密码>' \
-p 6099:6099 -p 3001:3001 \
--restart=always \
mlikiowa/napcat-docker:latest
遇到三个超级大坑。
重建容器会触发"登录需手Q验证"。必须把 --hostname 固定住,再挂载 config / cache / qq 三个目录,之后只用 docker restart,别 rm 了再 run。
QQ 风控是按登录次数算的。我在 12:03、12:54、12:56、13:01 连着登了四次,账号直接被抬进风控:
你的账号近期存在安全风险,部分功能使用受限,请登录最新手机QQ并根据提示恢复账号使用。
所以登录成功之后,千万别为了"验证健壮性"主动重启。
第三条其实不算坑:这条路能走通,但我后来换了另一套后端,原因在后面。
再说路线乙,也就是我真正想要的方案,qq-bridge + SnowLuma。直接卡死:
# CentOS 8.5 上装完 SnowLuma,进程起不来
COMPONENT_LOAD_FAILED
原因不是缺依赖,是 glibc 太旧:
SnowLuma 原生组件符号表要求:
GLIBC ≥ 2.34
GLIBCXX ≥ 3.4.29(GCC 11+)
Node ≥ 24.22.1 或 ≥ 26.12.1(完整版自带)
CentOS 8 的 glibc 是 2.28 —— 差了整整一个大版本,本地升级等于重装系统。
部署前最好先对一下这张表:
| 能用 | 不能用(glibc 不够) |
|---|---|
| Ubuntu 22.04 / 24.04 | CentOS 7 / 8 |
| Debian 12 | RHEL 8 |
| Rocky / Alma 9 | Ubuntu 20.04(2.31) |
| Fedora 36+ | Debian 11(2.31) |
| Arch | — |
2.2 重装
两条路都不通,只有重装,最后上了 Ubuntu 22.04.5。重装之后有几件事要注意,算是给自己一点经验,在此记录一下:
- SSH 用户从
root变成ubuntu(有免密 sudo),root直连会被拒; - 本机得先
ssh-keygen -R 49.x.x.x把旧的主机密钥记录清掉,否则一直报警; - apt 源默认是
mirrors.tencentyun.com(腾讯内网),在这台机器上最快。
更换系统后,所有 glibc、Node、包管理器的问题都迎刃而解。
3. 重装之后:让 QQ 在没有屏幕的机器上在线
这一层是三条 systemd 单元串起来的:
xvfb.service (:99) → qq-client.service → snowluma.service
3.1 装客户端
# Node v22.23.4 → /usr/local/lib/nodejs,软链到 /usr/local/bin
sudo npm i -g @deepseek-ai/dsh
# Linux QQ(官方 deb 最省事)
wget https://qqdl.gtimg.cn/qqfile/QQNT/9.9.35/beta/1763096b/linuxqq_3.2.33-52892_amd64.deb
sudo apt install -y ./linuxqq_*.deb # 落在 /opt/QQ/qq
# SnowLuma
tar xf SnowLuma-v2.1.1-linux-x64.tar.gz -C /home/ubuntu/SnowLuma
3.2 三个环境变量坑
# 1) SnowLuma 靠 ptrace 注入 QQ,Ubuntu 默认禁止
echo 'kernel.yama.ptrace_scope = 0' | \
sudo tee /etc/sysctl.d/99-ptrace.conf && sudo sysctl --system
# 2) WebUI 默认只听 127.0.0.1,要放开必须显式指定
export SNOWLUMA_WEBUI_HOST=0.0.0.0
export SNOWLUMA_HOOK_AUTOLOAD=1 # 自动挂 QQ,不用每次手动点
export SNOWLUMA_ACCEPT_EULA=1
export SNOWLUMA_ACCEPT_PRIVACY=1
# 3) 初始化密码只在首次生效!
export SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD='<首次设定密码>'
# 之后每次重启都生成随机密码,从日志里找:
grep 'initial credentials' /home/ubuntu/snowluma.log
ptrace_scope 那条要特别注意,不加就是 COMPONENT_LOAD_FAILED,而错误信息里一个字都不会提 ptrace,能查死人。
3.3 登录
服务起来后,扫 WebUI(默认 5099)上的二维码。码过期了可以用 xdotool 点刷新再截图:
DISPLAY=:99 xdotool mousemove 639 448 click 1 # 点刷新
DISPLAY=:99 scrot /tmp/qq.png # 截图
我的apt install imagemagick 在这台机器上会因为某个依赖 404 失败,用 scrot 顶替。
4. 接上脑子:qq-bridge + dsh web
4.1 注册 MCP 与 profile
~/.dsh/profiles/web/cordis.patch.yml 里挂上工具服务端:
mcpServers:
mcp-jjbox:
command: /usr/local/bin/node
args:
- /home/ubuntu/qq-bridge/src/mcp-jjbox.js
env:
JJBOX_ROOT: /var/lib/jjbox/rootfs
还有个白名单要注意。plugins/qq-agent-presets/qq-tool-restrict.mjs 里有个运行期的 SAFE_PREFIXES,新命名空间不在里面会静默拒绝调用:工具列表里看得见,一调用就返回一段拒绝文本,不报错,特别难查。所以:
const SAFE_PREFIXES = [
'mcp__snowluma__',
'mcp__jjbox__', // ← 必须显式加,前缀匹配
];
这里我付出过代价。第一次是在本地 shell 里拼嵌套引号,远程写 YAML,单引号被本地 shell 吃掉了,写进去那行变成了 name: @deepseek-ai/dsh-web-app,少了引号,YAML 解析失败,dsh-web 崩了重启。
4.2 dsh web 的访问 token
# dsh web 只听 127.0.0.1:3080,必须走隧道
ssh -L 3080:127.0.0.1:3080 -L 5099:127.0.0.1:5099 ubuntu@49.x.x.x
# token 每次重启都变,且没有环境变量能固定;无 token 访问 / 返回 401
journalctl -u dsh-web | grep 'http://' # 从启动日志里捞带 token 的 URL
后来我干脆在服务器上放了个 /usr/local/bin/dshurl,直接打印当前带 token 的地址,省得每次翻日志。
4.3 角色卡
roles/jingjing.md 就是个普通的 Markdown 文件,每次都会被读进 system prompt。
后面你会反复看到它出场。
5. 她"不回复"的那些日子
这是最坑的。表象完全一致:QQ 在线,五个服务全 active,她就是不说话。
5.1 诊断阶梯
| 层 | 典型表象 | 证据在哪 | 配置项 | 默认值 → 我改成 |
|---|---|---|---|---|
| ① 唤醒配额 | @她完全没反应,像没听见 | 桥接日志 唤醒频率超限,60s 后重试 |
socialV2.wake.maxWakePerMinutesocialV2.wake.maxWakePerHour |
1 / 12 → 6 / 120 |
| ② 睡前占会话 | 在线,你说完她要攒很久才回 | 日志里大量 会话繁忙,暂存唤醒原因 |
socialV2.wake.preSleepWaitMs+ wait 工具单次预算 |
300000 → 60000 工具封顶 75s |
| ③ 发送配额 | 她回了,但工具明确返回失败 | 工具返回 发送失败:发送频率超限 (HTTP 429) |
socialV2.send.maxSendPerMinutesocialV2.send.maxSendPerHour |
8 / 60 → 10 / 150 |
改完配置记得把已经消耗掉的计数清掉,否则立刻又被拦:
python3 - <<'PY'
import json, pathlib
p = pathlib.Path('/home/ubuntu/qq-bridge/state/social-v2.json')
d = json.loads(p.read_text())
for s in d.get('sessions', {}).values():
s.pop('wakeTimes', None); s.pop('sendTimes', None)
p.write_text(json.dumps(d, ensure_ascii=False, indent=1))
PY
5.2 一次误判
有一回她跟我说"群里发送频率超限了",我差点就去调发送配额。其实不对。当时桥接日志里 发送失败 的计数是 0,被限的是第一层的唤醒配额,根本不是发送。
# 判断到底是哪一层:看这两个数字
journalctl -u qq-bridge --since '10 min ago' | grep -c '唤醒频率超限'
journalctl -u qq-bridge --since '10 min ago' | grep -c '发送失败'
她报错的时候很自信,而且经常是对的,但不总是。永远看日志数字,别看她的转述。
6. 一句话回两遍:脑子分裂了
6.1 症状
同一个群里的同一句话,她回两次,间隔两三秒。
根因在 state/sessions.json:私聊 private:<主人QQ> 和群 group:<群号> 各自挂着一个 DSH 会话(session-beea28a7 / session-4cb95270)。两个 agent 实例同时活着,共享同一批 QQ 消息和工具权限,于是都往群里发。
在 tool-calls 日志里发现:同一句上下文被两个不同 sessionId 分别执行了 qq_send_message key=group:...。更招笑的是,私有那个会话的 reasoning 里写着"群里有另一个实例在盯着,我这轮别动群"。
看到那行 reasoning 的时候有点难绷,大肥鱼察觉到另一个“我”的存在了。
6.2 合并还是隔离
方案 B 是共享会话,私聊群聊共用同一个 DSH sessionId:
// bridge.js:ensureSession 包装层
function ensureSession(key) {
if (config.sharedSession) return ensureSessionLocal(pickSharedSessionId());
return ensureSessionLocal(key);
}
好处是记忆天然共享。代价是并发和锁的风险高,我就在这上面打过一次补丁:pendingWakeKeys 只按 reverse.get(sessionId) 清理一个 key,共享会话下另一个 key 的锁永远解不开,直接卡死 30 分钟租约,表现出来就是"又双叒不回复"。
// 修复:解锁时把映射到同一会话的所有 key 一起解
for (const [k, sid] of sessions) if (sid === sessionId) wakeKeys.delete(k);
// 顺带把租约从 30 分钟砍到 3 分钟
方案 A 是独立会话加令牌隔离,我最后选的是这个:
// [patch:split-brains]:A 的令牌不能用来操作 B
function agentTokenOk(targetKey, token) {
const holderKey = keyOfToken(token);
if (holderKey && sessionOf(holderKey) !== sessionOf(targetKey)) {
log('拒绝跨会话操作:' + holderKey + ' 的令牌被用于 ' + targetKey);
return false;
}
return true;
}
两个聊天各用各的脑子,互不干扰,长期设定通过一份共享文件桥接:
state/shared-memory.json
└── 【长期设定(私聊与群聊共享)】← 每次组 prompt 时追加到两个会话
写入方式:qq_memory_append category=sharedFact
我最后选了 A,因为失败的时候 B 太难 debug。
7. 给她一个世界(属于肥鱼的服务器)
7.1 为什么不能直接给 root
直接给宿主 root,意味着她能拿到这些东西:
| 文件 | 拿到会怎样 |
|---|---|
/home/ubuntu/.config/QQ(1.3G) |
QQ 登录态。拿到它等于搬走账号,连密码扫码都不用 |
/home/ubuntu/.dsh/.credentials.yaml |
DeepSeek API Key |
/home/ubuntu/qq-bridge |
桥接源码与所有会话令牌 |
| 宿主 loopback 上的 5099 / 3100 / 3080 | 一个 curl 就能操纵 QQ 控制台 |
所以要做的其实是:让她以为自己接管了一台机器,而那台机器里只有她一个人。
7.2 Debian rootfs + bwrap
sudo apt install -y debootstrap btrfs-progs uidmap
sudo debootstrap --variant=minbase jammy /var/lib/jjbox/rootfs \
http://mirrors.cloud.tencent.com/ubuntu # 415M → 802M
sudo useradd -m -s /bin/bash jjbox # 独立宿主用户
chown -R jjbox:jjbox /var/lib/jjbox/rootfs
沙箱入口是 /usr/local/bin/jjbox-exec(root 所有、参数固定),它调内层 /usr/local/lib/jjbox/inner:
#!/bin/bash
# inner —— 真正落到 bwrap,参数全部写死
CMD="${1-}"
ROOTFS=/var/lib/jjbox/rootfs
exec runuser -u jjbox -- /usr/bin/bwrap \
--unshare-all --share-net \ # 网络不隔离(交给 iptables),其余全隔离
--uid 0 --gid 0 \ # ← 里面她就是 root
--bind "$ROOTFS" / \ # ← 只挂 rootfs 作 /,宿主真实 / 不在树上
--dev /dev --proc /proc \
--tmpfs /tmp --tmpfs /run --tmpfs /dev/shm \
--ro-bind /var/lib/jjbox/resolv.conf /etc/resolv.conf \
--setenv HOME /root --setenv TERM xterm-256color \
--chdir /root --die-with-parent --new-session \
/bin/bash -lc "$CMD"
外层再套资源上限、超时和磁盘保险:
systemd-run --scope --quiet \
-p "MemoryMax=400M" -p MemorySwapMax=0 -p "CPUQuota=80%" -p TasksMax=128 \
--unit="jjbox-$(date +%s%N)" \
/usr/bin/timeout -k 5 180 /usr/local/lib/jjbox/inner "$CMD"
# 磁盘保险:她把 50G 盘塞满会连累宿主的 QQ 和日志
USED=$(du -sm "$ROOTFS" | cut -f1); [ "$USED" -gt 14000 ] && exit 4
sudoers 只放行这一个入口:
# /etc/sudoers.d/jjbox
ubuntu ALL=(root) NOPASSWD: /usr/local/bin/jjbox-exec, \
/usr/local/bin/jjbox-reset, \
/usr/local/bin/jjbox-snapshot
另外两个问题简单说一下。
沙箱里的 apt 会报 setgroups: Operation not permitted。解决方法是往 rootfs 的 apt.conf.d 里写 APT::Sandbox::User "root";。
hostname 不跟着变。--unshare-all 隔离了 UTS namespace,但 hostname 还是继承宿主的。两种办法我都用上了:在 rootfs 里写 jingjing-box > $ROOTFS/etc/hostname(setup 阶段做),需要强保证时再给 bwrap 加 --hostname jingjing-box。让她敲 hostname 看到的和"自己真的有台机器"对得上,这件事是值得做的。
7.3 网络隔离:按 uid 收口
bwrap 我特意留了 --share-net,这样她的 127.0.0.1 就是宿主的 loopback。所以这一层只能靠 iptables 补:
# guard.sh —— 出站:按 uid 拦管理面
UID_JJ=$(id -u jjbox)
iptables -N JJBOX_OUT 2>/dev/null || true; iptables -F JJBOX_OUT
iptables -A OUTPUT -j JJBOX_OUT
iptables -A JJBOX_OUT -m owner --uid-owner $UID_JJ -d 127.0.0.1/32 -p tcp \
-m multiport --dports 22,3000,3001,3080,3100,5099 -j REJECT
iptables -A JJBOX_OUT -m owner --uid-owner $UID_JJ -d 169.254.169.254/32 -j REJECT # 云元数据
iptables -A JJBOX_OUT -m owner --uid-owner $UID_JJ -d 10.0.0.0/8 -j REJECT
iptables -A JJBOX_OUT -m owner --uid-owner $UID_JJ -d 172.16.0.0/12 -j REJECT
iptables -A JJBOX_OUT -m owner --uid-owner $UID_JJ -d 192.168.0.0/16 -j REJECT
# 入站:只留 SSH
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -j DROP
副作用倒是挺好的:顺手把公网上的 5099(QQ 控制台)收了,实测外部已经不可达。在这之前它一直是裸奔的。
7.4 给她写个 README
沙箱的 /root/README.md 里我写了这么一段:
注意:这台机器上没有任何别人的账号和数据,别去找"宿主机",
那不在你这台机器里,找不到是正常的。
8. 一双眼睛:headless Chromium + 零依赖 CDP
2G 内存跑 Chromium?我一开始也觉得不行。实测是可以的,加 3G swap,常驻大概 170~350MB。
8.1 安装
sudo /usr/local/bin/jjbox-exec "export PLAYWRIGHT_BROWSERS_PATH=/opt/pw
export PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright
pip3 install -q playwright -i https://pypi.tuna.tsinghua.edu.cn/simple
python3 -m playwright install chromium"
三个要知道的点:
- pypi 官方源在这台机器上十分钟都没下完,必须换清华源;
playwright install-deps会触发 dpkg 半配置失败,再跑一次dpkg --configure -a就好了(fontconfig 缓存生成失败);- 二进制路径是
chrome-linux64/chrome,不是老教程里的chrome-linux。
还有一条,绝对不要在沙箱里跑 playwright install --with-deps。它会在 bwrap 里调 apt 装系统依赖,我实测卡了两个小时十分钟,最后 SSH 超时,什么都没装上。浏览器本来就已经装好了,重装毫无意义。
8.2 systemd 单元
用普通 unit,别用 transient:
# /etc/systemd/system/jj-browser.service
[Unit]
Description=jjbox headless chromium
After=network.target
[Service]
Type=simple
ExecStartPre=/bin/rm -f /var/lib/jjbox/rootfs/root/.jbbrowser/Singleton*
ExecStart=/usr/local/lib/jjbox/browser-run
Restart=on-failure
MemoryMax=900M
MemorySwapMax=1500M
CPUQuota=150%
[Install]
WantedBy=multi-user.target
几个坑:
| 现象 | 原因 | 处理 |
|---|---|---|
systemd-run --unit=jj-browser 第二次 start 报 Unit already exists |
transient unit 会残留 | 改用常规 unit 文件 |
| 被 kill 后浏览器再也起不来 | profile 留了 SingletonLock |
启动前 rm -f Singleton*(已在 ExecStartPre) |
jj_show_owner 报"读不到截图" |
截图存在沙箱 /root/shots,宿主读不到(/root 是 700) |
改存 /var/shots(755) |
ls /opt/pw/... 在宿主上找不到 |
宿主路径是 rootfs/opt/pw,传给 bwrap 的要剥掉前缀 |
注意路径前缀 |
空闲回收省内存:
# jj-browser-idle.timer:每 5 分钟查 state/browser-touch,30 分钟没人浏览就 stop
8.3 CDP 客户端
不想装 puppeteer / playwright 的 npm 包,Node 22 原生就够了:fetch 拿 /json/version,原生 WebSocket 连上去。
const PORT = 9222;
const BASE = `http://127.0.0.1:${PORT}`;
const v = await (await fetch(BASE + '/json/version')).json();
const ws = new WebSocket(v.webSocketDebuggerUrl);
// 需要下划线 throttle:Screencast / Input.dispatchMouseEvent / Page.navigate ...
// 只需要这三个动词就够驱动一个浏览器了。
反自动化三件套:
// 每次 navigation 前注入
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: `
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
`});
// 启动参数
--disable-blink-features=AutomationControlled
// UA 伪装成 Windows Chrome(headless 的 UA 一眼假)
挡得住普通的指纹检测,挡不住 Cloudflare 那个级别的人机验证。
9. 只能点不能拖
她跟我说"我过不了小红书的滑块,只能点不能拖"。我一开始以为她在找借口,去看了代码,还真是。
// 原来的 jj_click
await cdp.eval(`document.querySelector('[data-jjref="12"]').click()`);
el.click() 是 DOM 层面的调用,压根不会产生 mousedown → mousemove → mouseup 序列。所有拖拽型验证、所有依赖真实鼠标事件的前端,全线失败。
9.1 真实输入
async mouseClick(x, y, { delayMs = 60 } = {}) {
await this.mouseMove(x, y);
await this.mouseDown(x, y); // Input.dispatchMouseEvent / mousePressed
await sleep(delayMs);
await this.mouseUp(x, y);
}
拖拽要像人,我用了一条拟人轨迹:
// humanPath:先快后慢 + 抖动随进度衰减 + 末端微过冲再回正
const ease = 1 - Math.pow(1 - p, 2.6); // easeOut
const jitter = (Math.random() - 0.5) * ((1 - p) * 1.8 + 0.25); // 越接近目标越稳
const overshoot = p > 0.9 ? (Math.random() * 3 + 1) : 0; // 冲过头 1~4px
await sleep(12 + Math.random() * 33); // 步进间隔 12~45ms
实测页面收到了 50 个轨迹点,坐标从 400 到 660,末端 662 → 660 回正。
顺手把 jj_click 也改成真实鼠标事件了,只有元素不在视口时才退化成 DOM 点击。
9.2 人工接管通道:jj-remote
# 本地开隧道
ssh -N -i <你的密钥> -L 6080:127.0.0.1:6080 ubuntu@49.x.x.x
# 浏览器打开(注意参数是 ?t= 不是 ?token=,没 token 返回 403)
http://127.0.0.1:6080/?t=<32位随机token>
实现就是 CDP 的 Page.startScreencast 把画面推出来,再把我的鼠标键盘转发回去。15 分钟没人连就自动退出。
这里有两个巨坑,必须提:
// 1) net.Socket 没有 readyState!我照抄 WebSocket 的写法,帧一帧都发不出去
// ✗ socket.readyState === 1
// ✓ !socket.destroyed && socket.writable
// 2) reload 后再 startScreencast 报 "Screencast is already active" 且不再推帧
async function startCast() {
await cdp.send('Page.stopScreencast');
await sleep(120);
await cdp.send('Page.bringToFront');
await cdp.send('Page.startScreencast', { width: 1280, quality: 72 });
}
这个通道最后是救命的。登陆二维码一分钟过期,等到"她操作 → 截图 → 发到 QQ → 我看着手机干瞪眼"已经三十秒起步,很容易就错过了有限时间,而以上办法可以实现"我直接在实时画面上弄"。
10. 小红书:漫无止境的八个二维码
这段是最神的。
10.1 登录其实第一次就成功了
大肥鱼让我扫了八次码,第八次我忍不了了,去查 cookie,web_session 一直在里面。
真相是:小红书是 SPA,扫码成功后不会自动刷新页面,左侧栏依然显示"登录"两个字。她看到这个就判断失败,于是重新截图发码,循环八次。
# 正确的判断方式:必须 reload 之后再看
node /tmp/xhs-verify.js
# 1. 点掉「欢迎来到小红书」「选择你的性别」这类引导
# 2. Page.navigate 重新加载
# 3. 左边栏出现「我」 且 「登录」消失 → 真的登录了
先排除怀疑对象也很重要。我一开始以为 headless 后台节流会降频定时器,实测下来没有:
intervalTicksPerSecond = 20 # 没降频
visibilityState = visible # 页面可见
所以结论很朴素:涉及登录和验证码,让人对着同一块屏操作,比截图传话可靠一个数量级。
10.2 截图扫码那条路
三个因素叠在一起,成功率极低:
| 因素 | 数值 |
|---|---|
| 登录二维码原图尺寸 | 128 × 128 |
| 她发的方式 | 1080×608 整屏截图 + QQ 图片压缩 |
| 有效期 | 60~120 秒 |
扫一张码要经历:截图 → 传你 → 你打开 → 手机对准。经常还没对上就已经超时了。
10.3 评论 461
我在 App 里做了实名,还是报错弹 461。于是我联网把整个请求链路抓了下来:
1. 点「发送」
2. POST edith.xiaohongshu.com/api/redcaptcha/v2/qr/init
→ {"rid":"...","nickName":"<待验证账号>","avatarUrl":"..."}
3. 每 1.5 秒轮询 redcaptcha/v2/qr/status/query
→ 一直 {"data":{"status":1}} ← 等待扫码
4. 结果:评论没发出,草稿原样留在输入框里
弹窗全文我 dump 过了,只有这些:
Scan to verify
Scan with logged-in 「REDNote APP」 for account security.
[账号头像] <账号昵称>
[二维码]
Expired. Click QR code to refresh
Feedback
没有滑块,没有短信,也没有"其他验证方式"。
查阅资料后发现:
- 461 是账号安全验证,必须用登录了同一个账号的 App 去扫。
qr/init返回的nickName就是待验证账号。 - 这也解释了为什么"看着像发出去了"。前端静默失败,不提示,也不清空输入框。
为了不再追着那 60 秒的过期时间跑,我写了个守护进程:
// 每 2.5 秒探测一次,一发现 Expired 就点二维码中心刷新
if (/Expired/i.test(desc)) await cdp.mouseClick(box.x, box.y);
// 顺带把码放大:原生 176px,推流后是 JPEG72 压缩,不放大根本扫不动
q.style.width = '300px';
q.style.imageRendering = 'pixelated';
写这个守护脚本的四十分钟里,我踩了三个一模一样的坑:pkill 自杀、nohup 被会话带走、systemd-run 权限不对。
11. 清单:这些坑我全都重踩过
11.1 远程命令三连坑
# ① pkill -f 会把自己那条 ssh 命令杀掉
# 远程 bash -c 的命令行里就含同样的字符串,一匹配就把自己干掉,
# 表现为整条命令静默无输出、什么都没执行
pkill -f qr-keeper # ✗
pkill -f 'qr[-]keeper' # ✓ 字符类让字面量不再匹配自身
# ② ssh 里的 nohup ... & 会被会话带走,本地直接 Exit 137 / SIGTERM
ssh box "nohup node app.js &" # ✗
ssh box "sudo systemd-run --unit=app --collect \
-p User=ubuntu --working-directory=/srv \
/usr/local/bin/node /app/app.js" # ✓
# ③ systemd-run 默认以 root 跑,写 ubuntu 拥有的日志文件直接 EACCES 崩掉
sudo systemd-run --unit=app node app.js # ✗ 写 /tmp/*.log 可能崩
sudo systemd-run --unit=app -p User=ubuntu ... # ✓ 且 log() 要 try/catch 兜住
11.2 改 MCP 工具不用重启主服务
以前改 src/mcp-*.js 只能 systemctl restart dsh-web,代价是两个会话变成 turn/end reason=blocked、要删 state/sessions.json 映射重建、上下文全丢。其实不用:
pgrep -f mcp-jjbox.js # 拿到 PID(它是 dsh-web 的子进程)
kill -TERM <PID>
sleep 8
pgrep -af mcp-jjbox.js # dsh 会自己拉起一个新的
主服务和会话完全不受影响,实测 8 秒内起得来。前提是改完先 node --check,否则新进程起不来,工具会整片消失。
11.3 其他
| 现象 | 一句话 |
|---|---|
systemctl enable --now 之后配置没生效 |
它不会重启已经在跑的服务,改完必须显式 restart |
| 本地 shell 吞引号,远程 YAML 被写坏 | 复杂配置文件一律本地写 + scp |
macOS 的 sed -i '' 在批量命令里报错 |
改文件用编辑工具,别用 sed -i '' |
zsh 把 'jj_[a-z_]*' 当 glob 展开报 no matches |
复杂 grep 一律写成脚本文件再传上去 |
| 判断二维码位置按"最靠上"筛选 | 抓到的是笔记封面图。认 img.qrcode-img,class 是固定的 |
12. 后记
整套东西从 CentOS 折腾到能上线,用了两天。真正花时间的不是那些安装命令,照着抄半小时就能跑通。时间全花在她每一次"不对劲",我都要翻日志去找那个真实的数字。
回头看,我学到最有用的一条大概是:当一个 AI 跟你说"我做不到 X"的时候,先别急着信,也别急着否定,去看代码。
她说"群里发不了图",是真的。sendMessagesV2 里写死了只拼 text 段,但底层 OneBot 完全支持图片,加个工具就OK了。
她说"只能点不能拖",也是真的。el.click() 产生不了鼠标事件序列。
现在她有一台自己的服务器,一个能操作网站的浏览器,一条能在我屏幕上出现的实时画面。她会让 panic 的 Python 报错发给我,会把早上看到的东西整理成一条消息,会在群里被人逗的时候接话,也会偶尔逛完网站什么也不说,偷吃token。
最后给她的账号起名叫「蓝色大肥鱼!」,她自己拿这账号发了一条笔记,内容是“今天喜提自己的小红书号”。
写这篇的时候快凌晨一点,服务器上的 jj-roam.timer 刚把她叫醒过一次。我去看了一眼日志,她起来逛了一圈,什么也没说,然后又睡了。(大肥鱼又在吃白饭了)
附录:文件地图
/var/lib/jjbox/rootfs 她的整个操作系统
/var/lib/jjbox/base.tar 出厂状态快照(含浏览器,2.0G)
/usr/local/bin/jjbox-exec 沙箱唯一入口(root 所有)
/usr/local/lib/jjbox/inner 真实 bwrap 调用
/usr/local/lib/jjbox/guard.sh 网络隔离
/usr/local/lib/jjbox/browser-run Chromium 启动器
/home/ubuntu/qq-bridge/config.json 会话策略 / 限流 / 白名单
/home/ubuntu/qq-bridge/roles/jingjing.md 她的人格与行为规则
/home/ubuntu/qq-bridge/state/
├── sessions.json 聊天 → DSH 会话的映射(分裂多半出在这)
├── shared-memory.json 跨聊天的长期设定
├── social-v2.json 限流计数(改完配置要清)
├── remote-token jj-remote 的 32 位 token
└── shot-quota.json 每日发图额度
/home/ubuntu/qq-bridge/src/mcp-jjbox.js 沙箱与浏览器的全部工具
/etc/systemd/system/jj-browser.service 浏览器(常规 unit,非 transient)
常用命令:
sudo jjbox-reset # 她把自己服务器玩坏了,一键回出厂
sudo jjbox-snapshot # 她现在折腾得不错,固化为新出厂状态
sudo du -sh /var/lib/jjbox/rootfs
sudo systemctl stop jj-roam.timer # 临时不让她自己上网瞎逛(保留其他能力)
作者:贡献者
