杂谈

IP、令牌、QQ 号、账号数字已经全换成占位符,按你自己的情况填。

机器是腾讯云 2C2G、50G 盘。


0. 这套东西最后能干什么

一只能长期运行的大肥鱼。可以聊天,记得住你交代的事,能自己上网翻东西,能把截图发到你 QQ 上。另外她每 40 分钟会自己醒一次,出去逛一圈,回来讲她看到什么,当然也可能什么都不讲。

还有一台"她自己的服务器"。在里头她是 root,能 apt install,能装包,能起进程。但这台服务器是假的,用 debootstrap 加 bwrap 做的沙箱。宿主上的 QQ 登录态、API Key、桥接源码,在她的文件系统里物理上不存在。

实现过程中遇到不少问题:

  1. 怎么让 QQ 在一台没屏幕的 Linux 上长期在线;
  2. 她"为什么不回复"——这个问题我查了三轮,最后发现是三层不同的限流叠在一起;
  3. 她"为什么一句话回两遍"——因为她分裂出了两个脑子;
  4. 怎么给她自由,同时保证她跑不出笼子;
  5. 怎么给一个只有文字界面的 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.maxWakePerMinute
socialV2.wake.maxWakePerHour
1 / 12 → 6 / 120
② 睡前占会话 在线,你说完她要攒很久才回 日志里大量 会话繁忙,暂存唤醒原因 socialV2.wake.preSleepWaitMs
+ wait 工具单次预算
300000 → 60000
工具封顶 75s
③ 发送配额 她回了,但工具明确返回失败 工具返回 发送失败:发送频率超限 (HTTP 429) socialV2.send.maxSendPerMinute
socialV2.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

没有滑块,没有短信,也没有"其他验证方式"。

查阅资料后发现:

  1. 461 是账号安全验证,必须用登录了同一个账号的 App 去扫。qr/init 返回的 nickName 就是待验证账号。
  2. 这也解释了为什么"看着像发出去了"。前端静默失败,不提示,也不清空输入框。

为了不再追着那 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     # 临时不让她自己上网瞎逛(保留其他能力)
This is just a placeholder img.