从零搭一套「手机派活」系统:完整命令与踩坑手册
目标:手机派活 → 服务器排队 → 家里的电脑执行 → 结果回手机。
全程零外部依赖,一台服务器 + 一台电脑,半小时到一小时能跑通。
本文所有命令都可直接复制,把尖括号占位符换成你自己的即可。
第 0 步:先确认你要付出什么
| 项 | 要求 | 说明 |
|---|---|---|
| 服务器 | 1 核 512MB 起,Node ≥ 16 | 常年在线,有公网 IP |
| 家里电脑 | Node ≥ 16,Windows / macOS / Linux 都行 | 不用常开,开了就自动接任务 |
| 手机 | 任意现代浏览器 | 加到主屏幕即可,不用装 App |
| 成本 | 服务器月费 + 少量 API 费用 | 任务可以完全不调 API |
架构一句话:家里那台机器主动连出去领任务,所以不需要公网 IP、不需要内网穿透、不需要动路由器。
第 1 步:服务器准备
1.1 连接方式:用密钥,不用密码
在本机生成密钥对(Windows 的 PowerShell 里同样适用):
ssh-keygen -t ed25519 -f ~/.ssh/pocket -N ""坑 1:
-N ""在 PowerShell 里可能被解析成字面量引号,生成带密码短语的密钥。生成后验证一句:ssh-keygen -y -f ~/.ssh/pocket能直接打印出公钥就是没加密;提示输入 passphrase 就是加密了,重新生成。
把公钥装到服务器上。注意用户名不一定是 root,很多云厂商的默认用户是ubuntu、centos、lighthouse之类:
# 在服务器上执行(通过云厂商控制台的网页终端登录)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' '<把你的公钥整行粘在这里>' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
grep -c 'ssh-ed25519' ~/.ssh/authorized_keys最后一行返回 1 就成功了。
坑 2:如果公开密钥要装到
/root/.ssh/,而你不是 root,会报Permission denied。用~/.ssh(自己的家目录)最省事。真要装 root,得sudo mkdir -p /root/.ssh并sudo tee追加。
1.2 验证免密登录
在本机执行(注意用户名换成服务器实际用户):
ssh -i ~/.ssh/pocket <用户名>@<服务器IP> "whoami"返回用户名,接着传文件:
scp -i ~/.ssh/pocket <本地文件> <用户名>@<服务器IP>:~/ 坑 3:第一次连接会问
Are you sure you want to continue connecting,输yes即可。如果之后同一台机器指纹变了,不要随手输 yes,先查清楚是不是重装或被劫持。
第 2 步:装 Node(这一步坑最多)
2.1 先看系统版本
cat /etc/os-release | head -3
node -v 2>/dev/null || echo "无 node"2.2 分情况处理
情况 A:Ubuntu / Debian,且 glibc ≥ 2.28(主流新系统)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v情况 B:CentOS 7 或任何 glibc 2.17 的老系统
官方 Node 18/20 二进制要求 glibc ≥ 2.28,直接装会报 GLIBC_2.28 not found。两条路:
# 路线 1:用官方提供的 glibc-217 兼容构建
curl -fsSL https://unofficial-builds.nodejs.org/download/release/index.json \
| tr ',' '\n' | grep -o '"version":"v20\.[0-9]*\.[0-9]*"' | head -1
# 拿到版本号后(假设是 v20.x.y):
curl -fSL -o /tmp/node.tar.gz \
https://unofficial-builds.nodejs.org/download/release/v20.x.y/node-v20.x.y-linux-x64-glibc-217.tar.gz
sudo rm -rf /opt/nodejs && sudo mkdir -p /opt/nodejs
sudo tar -xzf /tmp/node.tar.gz -C /opt/nodejs --strip-components=1
sudo ln -sf /opt/nodejs/bin/node /usr/local/bin/node
sudo ln -sf /opt/nodejs/bin/npm /usr/local/bin/npm
/opt/nodejs/bin/node -v# 路线 2:退回 Node 16(最后支持 glibc 2.17 的大版本)
curl -fSL -o /tmp/node16.tar.gz https://nodejs.org/dist/v16.20.2/node-v16.20.2-linux-x64.tar.gz
sudo rm -rf /opt/nodejs && sudo mkdir -p /opt/nodejs
sudo tar -xzf /tmp/node16.tar.gz -C /opt/nodejs --strip-components=1
sudo ln -sf /opt/nodejs/bin/node /usr/local/bin/node
/opt/nodejs/bin/node -v坑 4(最隐蔽):
sudo会重置PATH(secure_path里通常没有/usr/local/bin),所以软链接建好了,sudo node -v依然报 command not found。
解法:① 别依赖 PATH,用绝对路径/opt/nodejs/bin/node;② 在脚本开头加export PATH="/usr/local/bin:$PATH"。坑 5:如果代码用了
fetch(Node 18+ 才有),退回 Node 16 就跑不起来。要么自己写三十行http/https客户端,要么装node-fetch。前者更干净。
第 3 步:部署控制面
3.1 放文件
mkdir -p ~/pocket-agent/public
# 把 server.mjs 传到 ~/pocket-agent/,把前端 index.html 传到 ~/pocket-agent/public/3.2 生成配置(令牌自己生,别手编)
cd ~/pocket-agent
UI=$(/opt/nodejs/bin/node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))')
WK=$(/opt/nodejs/bin/node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))')
cat > config.json <<JSON
{
"port": 8787,
"bind": "0.0.0.0",
"uiToken": "$UI",
"workerToken": "$WK",
"presets": [
{ "label": "示例脚本", "script": "demo" }
],
"ntfy": null
}
JSON
chmod 600 config.json
echo "uiToken=$UI"; echo "workerToken=$WK"把两个令牌抄下来,关掉终端就找不到了(虽然还能 cat config.json)。
3.3 装成系统服务
sudo useradd --system --create-home --shell /sbin/nologin pocket || true
sudo mkdir -p /opt/pocket-agent
sudo cp ~/pocket-agent/server.mjs /opt/pocket-agent/
sudo cp -r ~/pocket-agent/public /opt/pocket-agent/
sudo cp ~/pocket-agent/config.json /opt/pocket-agent/
sudo chown -R pocket:pocket /opt/pocket-agent
sudo chmod 600 /opt/pocket-agent/config.json
sudo tee /etc/systemd/system/pocket-agent.service >/dev/null <<'UNIT'
[Unit]
Description=Pocket Agent control plane
After=network.target
[Service]
Type=simple
User=pocket
WorkingDirectory=/opt/pocket-agent
ExecStart=/opt/nodejs/bin/node /opt/pocket-agent/server.mjs
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now pocket-agent
sudo systemctl is-active pocket-agent坑 6:
ExecStart里必须写绝对路径的 node,别写node。原因同坑 4,systemd 的 PATH 和你的 shell 不一样。
3.4 本机自检
curl -s http://127.0.0.1:8787/api/state -H "x-token: <uiToken>"返回一段 JSON(含 "workerOnline":false)就说明服务起来了。
坑 7:如果返回
Connection refused,先看日志:sudo journalctl -u pocket-agent -n 30 --no-pager最常见的是
EADDRINUSE(端口被占用)或配置写错(少个逗号)。
3.5 放行端口(两处,缺一不可)
第一处:云厂商控制台的安全组/防火墙
进控制台 → 找到你的实例 → 防火墙 → 添加规则:
协议:TCP 端口:8787 来源:0.0.0.0/0 策略:允许第二处:系统自带防火墙(如果开了)
# firewalld
sudo firewall-cmd --permanent --add-port=8787/tcp && sudo firewall-cmd --reload
# ufw
sudo ufw allow 8787/tcp从本机验证:
curl -s http://<服务器IP>:8787/api/state -H "x-token: <uiToken>"坑 8:只放行了一处,表现是「服务器上 curl 通,外面 curl 不通」。两处都要查。
第 4 步:部署执行端(家里的电脑)
4.1 配置文件
worker.config.json:
{
"serverUrl": "http://<服务器IP>:8787",
"workerToken": "<workerToken>",
"workspace": "<你的工作目录绝对路径>",
"pollWaitSec": 25,
"logPushMs": 1200,
"timeoutSec": 1800,
"dsh": { "cmd": "node", "args": ["<Agent 入口 JS 的绝对路径>", "--profile", "headless"] },
"scripts": {
"demo": { "label": "示例脚本", "cmd": "node", "args": ["-e", "console.log('hello')"] }
}
}4.2 启动
node worker.mjs看到「worker 启动|服务器 http://…」就说明连上了。
坑 9(Windows 专属):如果配置里写
"cmd": "dsh",会报spawn dsh ENOENT。因为 Windows 上dsh是.cmd包装脚本,Node 出于安全考虑不允许无 shell 直接 spawn.cmd。
解法:找到它的 JS 入口(npm root -g可以查全局包目录),用node <入口>.js的方式启动,不要为了省事加shell: true——那会让任务参数里的特殊字符变成命令注入。
坑 10:任务一直显示「排队中」,先看执行端是否在线:curl -s http://<服务器IP>:8787/api/state -H "x-token: <uiToken>"
"workerOnline": true才说明心跳正常。如果启动后二十秒内还是 false,说明心跳间隔太长,应改成启动瞬间先发一次心跳。
4.3 安全设计:白名单必须在执行端
控制端只允许提交「任务类型 + 参数」,真正的命令映射写在执行端的配置里,遇到不认识的名字直接拒绝:
未授权的脚本:xxx坑 11:把命令映射放在服务器上(比如让网页直接传
{"cmd":"rm -rf /"})是最危险的做法。控制面是公网那一端,安全边界要放在受保护的那一侧。
第 5 步:手机端
5.1 打开并填令牌
浏览器访问 http://<服务器IP>:8787,首次会让你填 uiToken。
也支持一次性导入:http://<服务器IP>:8787/#t=<uiToken>,导入后地址栏会自动清掉。
5.2 加到主屏幕
- Chrome(Android):右上角菜单 →「添加到主屏幕」
- Safari(iOS):分享 →「添加到主屏幕」
坑 12:用 HTTP 访问时,Android Chrome 只能创建「快捷方式」,不能算真正的 PWA(可安装 PWA 需要 HTTPS)。功能不受影响,只是图标角标会有个小标识。
5.3 验证闭环
派一个脚本任务,等几秒:
curl -s "http://<服务器IP>:8787/api/tasks?limit=1" -H "x-token: <uiToken>""status":"done" 且 result 有内容,就算通了。
第 6 步:暂存区与本地同步(可选,但强烈建议)
用途:白天在手机上记的想法,先落在服务器,晚上开机再搬回家,云端不留副本。
6.1 服务器端加接口
需要实现四个:写、列表、读、确认删除。所有路径都要做越界检查:
path.resolve(base, 用户传入的路径) 必须仍以 base 开头,否则返回 400验证方式:
curl -s "http://127.0.0.1:8787/api/spool/file?p=../../etc/passwd" -H "x-token: <uiToken>"
# 期望 400,而不是把 /etc/passwd 吐出来6.2 建立暂存目录
sudo mkdir -p /srv/cloud/spool
sudo chown -R pocket:pocket /srv/cloud6.3 本地同步脚本(四步协议)
# 1. 拿清单
Invoke-RestMethod "http://<服务器IP>:8787/api/spool" -Headers @{ 'x-token' = '<uiToken>' }
# 2. 逐个下载 GET /api/spool/file?p=<相对路径>
# 3. 按规则写进本地目录
# 4. 只把「确实写成功」的路径提交到 POST /api/spool/ack坑 13:不要用 git 同步隐私内容。git 的历史是永久的——删掉文件再 commit,内容还躺在
.git里。对日记、想法这类东西,这等于没删。
必须「拉取成功才删」,服务器上物理删除。
坑 14:同步脚本要先写文件、再 ack。顺序反了(先删后写)会在网络中断时丢数据。
第 7 步:定时收集(可选)
抓几个 RSS 源,给新文章配一句话概括,双写:一份给手机看,一份进周报。
7.1 配置
{
"deepseek": { "apiKey": "", "model": "deepseek-chat" },
"feeds": [
{ "name": "a", "label": "某人博客", "url": "https://example.com/feed/" }
]
}apiKey 留空也能跑——摘要会退化成「原文前 60 字」。
7.2 定时器
sudo tee /etc/systemd/system/collect.service >/dev/null <<'UNIT'
[Unit]
Description=RSS collector
After=network-online.target
[Service]
Type=oneshot
User=pocket
ExecStart=/opt/nodejs/bin/node /srv/cloud/collect.mjs
UNIT
sudo tee /etc/systemd/system/collect.timer >/dev/null <<'UNIT'
[Unit]
Description=Run collector every 6 hours
[Timer]
OnCalendar=*-*-* 00/6:17:00
Persistent=true
Unit=collect.service
[Install]
WantedBy=timers.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now collect.timer
systemctl list-timers collect.timer --no-pager坑 15:首次运行只建基线,不要倒灌历史。否则订阅的瞬间会收到几十篇旧文,你根本不会看。
实现方式:判断「状态文件不存在」时,只记录已有条目为已读,不产出任何内容。
坑 16:RSS 的guid才是去重依据,不是标题也不是链接。用sha1(feedName + guid)存成集合,最省空间。
第 8 步:开机自启
8.1 服务器
已经在第 3 步用 systemctl enable 处理了。收集器是 enable --now xxx.timer。
8.2 家里的电脑
Windows:Win + R 输入 shell:startup,把启动脚本的快捷方式丢进去。
或者用管理员 PowerShell 建计划任务:
schtasks /create /tn "pocket-agent-worker" /tr "<启动脚本绝对路径>" /sc onlogon /rl highest坑 17:
/rl highest需要管理员权限,普通窗口会报「拒绝访问」。没有管理员权限就用shell:startup,效果一样。
Linux / macOS:用systemd --user或launchd,或者最土的办法——tmux new -d -s worker 'node worker.mjs'。
第 9 步:日常运维命令
# 控制面
systemctl status pocket-agent
journalctl -u pocket-agent -f
sudo systemctl restart pocket-agent
# 收集器
systemctl list-timers collect.timer
sudo systemctl start collect.service
journalctl -u collect -n 30 --no-pager
# 手动跑一次收集(不写文件,只看结果)
sudo -u pocket /opt/nodejs/bin/node /srv/cloud/collect.mjs --force --dry
# 看暂存区
ls -R /srv/cloud/spool
ls -l /srv/cloud/state/# 本地执行端
node worker.mjs
# 手动同步
powershell -ExecutionPolicy Bypass -File sync-cloud.ps1 -Token <uiToken>第 10 步:故障排查表
| 症状 | 最可能的原因 | 排查命令 | |
|---|---|---|---|
| 外面访问不了 | 云控制台防火墙没放行 | curl http://<IP>:<端口>/api/state 本机对比 | |
| 本机也访问不了 | 服务没起来 | systemctl status xxx | |
EADDRINUSE | 端口被老进程占着 | `ss -lntp \ | grep <端口>` |
node: command not found | sudo 的 PATH 里没有安装目录 | which node vs sudo which node | |
GLIBC_2.28 not found | 老系统 + 新二进制 | ldd --version 看 glibc | |
spawn xxx ENOENT | Windows 上 .cmd 不能直接 spawn | 改用 node <入口>.js | |
| 任务一直「排队中」 | 执行端没连上 | 看 /api/state 的 workerOnline | |
| 任务「完成」但没输出 | 子进程输出没被捕获 | 检查是否重定向到文件/管道 | |
| 401 | 令牌错或没带 | 检查请求头 x-token | |
| 403 需要执行端令牌 | 用手机令牌调了执行端接口 | 换 workerToken | |
| 手机端一直转圈 | 前端缓存了旧页面 | 强制刷新或清站点数据 | |
| 中文乱码 | 响应头缺 charset=utf-8 | 检查 content-type |
第 11 步:安全检查清单
上线前逐条确认:
- [ ] 令牌是随机生成的(16 字节以上),不是手工编的
- [ ]
config.json权限600 - [ ] 手机令牌和执行端令牌权限不同(手机不能调「领任务」「回传结果」)
- [ ] 执行端的命令映射是白名单,不是「执行我发来的字符串」
- [ ] 有超时保护,任务不会永久挂着
- [ ] 暂存接口做了路径穿越检查
- [ ] 日志里没有打印令牌
- [ ] 服务器上跑的进程不是 root
- [ ] 想清楚 HTTPS 什么时候上(明文传输令牌的窗口期尽量短)
- [ ] 想清楚哪些数据不能上云(这条比前面九条都重要)
最后:这套东西的价值在哪
它不是「AI 助手」,也不是「自动化平台」。它解决的是一个很具体的不对称:
你有想法的时间(白天、路上、床上),和你机器能干活的时间(晚上),是错开的。
传统做法是人迁就机器——想起来记在备忘录,晚上再翻出来干。这套东西做的是反过来:让机器迁就人的节奏。
所以核心不是模型多强,而是三条:队列(派活和干活解耦)、暂存(记下来不等设备)、搬运(数据落在该在的地方)。
模型只是其中最不重要的一环。先把管道修通,再谈智能。
本文基于一次真实搭建整理,所有踩坑均为实际遇到。



0 条评论