事情的起点只有一行命令:
npx -y github:974294341-bit/stdu-cli install
然后它返回了 exit 128,什么都没装。这篇记录从这行命令开始,到最后把 4 个文件推上 GitHub、顺手把 v2 服务端骨架跑通为止。全程没有一个报错是"按文档来就行"的,每一个都得自己挖。
一、环境先给你一记闷棍
npx github: 协议本质是 git clone。而这台机器上的 HTTPS git clone 是必然失败的:
fatal: unable to access 'https://github.com/...':
schannel: next InitializeSecurityContext failed: CRYPT_E_NO_REVOCATION_CHECK (0x80092012)
改 http.schannelCheckRevoke=false 没用,换成 openssl 后端又变成 unable to get local issuer certificate。更麻烦的是,raw.githubusercontent.com 也是死的(curl 直接 HTTP 000)。也就是说常规的三条下载通道(git / raw / codeload)全断了。
试了一圈,只有一条能通:
https://api.github.com/repos///contents/ ← 200
于是写了 fetch-repo.mjs:Node 调度 + child_process 调 curl,先拿 git/trees/HEAD?recursive=1 拿文件清单,再并发 8 路拉 contents API,响应里的 content 是 base64,解码后校验 size 再落盘,失败重试。36 个文件,全拉下来了。
经验 1:Node 内置 fetch 在这台机器上不走 http_proxy,慢到超时;必须用 curl。而 curl 对某些域名会莫名其妙地全天 HTTP 000(后面还会再踩一次)。
二、install 五步,三步有问题
手工跑 node src/cli.js install,五步里只有两步干净:
步骤
结果
① 复制 Agent 技能到 .workbuddy/skills / .claude/skills / .codebuddy/skills
✅
② npm install -g
❌ 静默跳过
③ playwright install chromium
⚠️ 下载卡死
④ 写 ~/.stdu-cli/config.json
✅
⑤ 打开 CAS 登录
⏱ 超时(没人操作)
② 的原因很 Windows:
await runCommand('npm', ['install', '-g', PKG_ROOT]); // spawn('npm') 不带 shell → ENOENT
Windows 上 npm 的真实名字是 npm.cmd。spawn('npm', ...) 不带 shell: true 必然 ENOENT,而代码里还把它包在 try/catch 里静默吞掉了 —— 结果就是全局安装被跳过,config.json 里的 run 退化成 npx -y github:...。而 npx 在这台机器上压根拉不动仓库,等于整个 CLI 废掉一半。
③ 是代理掐大文件:它想补一个 118MB 的 chrome-headless-shell,临时文件 0 字节无限重试。实际上 chromium-1248 本体十月份就装好了(有 INSTALLATION_COMPLETE 标记),根本不影响使用,kill 掉子进程后流程自动降级继续。
补做的两件事:npm pack + npm install -g stdu-cli-0.1.0.tgz(bash 里的 npm 正常),然后手工把配置里的 run 从 npx 改回 stdu-cli。这步不改,后面全白搭。
三、真正的 bug:登录了,但一查就崩
重跑 stdu-cli login,浏览器弹出来,CAS 走完,回跳到正方教务首页 —— 登录明明成功了。但紧接着:
stdu-cli course --today

→ Edge 报「正在另一个会话中打开」

这条报错把我引向了一条完整的因果链,也是今天最有价值的部分:
login.js 只调 saveSession() 存了入口 URL
↓ 从来没调用 persistSession() 导出 cookie
storage-state-edge.json 里一直是 10-26 的过期 cookie
↓
守护进程 headless 启动 → 判定「未登录」
↓
upgradeToInteractive() 弹出一个可见 Edge,占住 profile
↓
父进程等超时 → 回落直连 → 又开一个 Edge
↓
两个 Edge 抢同一个 --user-data-dir
↓
「正在另一个会话中打开」→ INTERNAL_ERROR
根因不在浏览器,在于 cookie 从来没落盘。 session.js 里 persistSession() 早就写好了,只是没有任何地方调用它。CAS 的 cookie 是会话级的,浏览器上下文一关就丢,所以每次查询都从头开始判定未登录。
修法就一行,但位置很关键 —— 必须在 context.close() 之前:
// src/modules/login.js
await persistSession({ selection, context }); // ← 补上
saveSession(selection.id, { entryUrl: page.url(), at: Date.now(), browser: selection.id });
bootstrap.js 同样补上。
顺手修了另外两个:

  • browser.js 缺 selectBrowser() 导出 —— main.js import 了但模块没导出,stdu-cli browser use chromium 直接报 selectBrowser is not a function。补上实现(写 config.json 的 browser 字段 + 校验已安装),并让 getSelectedBrowser() 支持优先级:env STDU_BROWSER > config.browser > 首个已安装 > chromium。
  • install.js 的 npm —— const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'。
    npm test 8/8 通过。重新 pack 安装后,端到端全绿,而且都走守护进程、几秒返回、不再重复弹登录:
    命令
    结果
    course --today
    工程实训与生产劳动 @ 工训教室,周三 1-4 节
    grade --year 2025 --term 1
    9 门课,加权绩点 2.647
    room --weekday 3 --period 5-6
    10 间空闲教室(二教205,74 座)
    经验 2:报错信息指向的地方往往不是根因。Edge 说"另一个会话在占用",真正的问题是十行之外的 cookie 没写盘。先画因果链,再动手改。
    四、把改动推上 GitHub:四种渠道全试了一遍
    本机没有 gh,~/.ssh 只有 known_hosts 没有密钥,没有 GITHUB_TOKEN 环境变量。GitHub 连接器状态是 auth-required,而 reconnect 一次会话只能请求一次(第二次直接 already requested),授权完 env 里依然拿不到 token。
    最后用了一个 classic PAT + Git Data API 走通,好处是四个文件合成一个干净 commit,而不是 contents API 那种一个文件一次提交:
    GET /repos/{r}/git/ref/heads/main → 拿到 base commit sha
    GET /repos/{r}/git/commits/{sha} → 拿到 base tree sha
    POST /repos/{r}/git/blobs → 4 个文件各建 blob
    POST /repos/{r}/git/trees → 基于 base tree 建新 tree
    POST /repos/{r}/git/commits → 建 commit,parent 指向 base
    PATCH /repos/{r}/git/refs/heads/main → 移动分支指针
    结果:commit 847fd2e 落到 main,远端 blob sha 与本地逐一核对一致。token 用完即删,并且去 GitHub 把 PAT revoke 掉了。
    这一节还有两个坑值得记:
  1. bash 的 curl 对 api.github.com 全天 HTTP 000,但 PowerShell 的 Invoke-RestMethod 同一个地址返回 200。 同一个代理,两个 HTTP 客户端表现完全不同。遇到"GitHub 不通"先换客户端再下结论。
  2. PowerShell 5.1 会把无 BOM 的 .ps1 当 ANSI 读,里面的中文全变乱码,然后解析器报错报得莫名其妙。解法:脚本本体保持纯 ASCII,中文提交信息单独存成 UTF-8 的 txt,用 [IO.File]::ReadAllText($path, [Text.Encoding]::UTF8) 读进来。
    五、v2:手机端豆包用不了,那就换个形态
    下午冒出一个新需求:用户全是手机端豆包用户。
    结论是 —— 用不了,而且是架构级错配,不是"适配一下"的事。stdu-cli 需要 Node ≥ 20 + 命令行 + Playwright 桌面浏览器 + 持久化 profile;豆包 App 里没有终端、没有文件系统、没有常驻进程。它附带的 SKILL.md 只对 Claude Code / CodeBuddy / WorkBuddy 桌面端这类能执行命令的 Agent 生效。
    所以 v1 保持不动,另起 stdu-server/。设计上守了三条:
  3. 不复制任何教务请求代码,直接 import v1 的 session/course/grade/room。学校改版只改 v1 一处,v2 自动跟随。
  4. 多用户隔离复用 v1 自带的 STDU_CLI_HOME,每个用户一个目录,profile / cookie / cache 天然分开,v1 一行都不用改。
  5. 同用户请求串行,因为 v1 那个"两个浏览器抢同一个 profile"的坑我已经踩过一次了。
    会话生命周期是「开 → 注入 cookie → 查 → 导出 cookie → 关」,每次查完 persistSession() 把登录态续上,做到"只认证一次"。缓存课表 6h / 成绩 30min / 空教室 10min —— 缓存在这里不只是加速,更是风控:集中抓取很容易被学校判定异常。
    这里又踩到一个 Windows 特有的坑:
    const env = { ...process.env, STDU_CLI_HOME: dir }; // ❌
    const env = Object.create(process.env); env.STDU_CLI_HOME = dir; // ✅
    ...process.env 展开成普通对象后,丢失了 Windows 环境变量的大小写不敏感特性,v1 读 env['PROGRAMFILES(X86)'] 拿到 undefined,直接把 Edge 判成"未安装"。用原型链继承既保留大小写不敏感,又能覆盖指定变量。
    本地实测(拿 v1 导出的 cookie 造了个 demo 用户):/v1/course 1 门课、/v1/grade 9 门课 GPA 2.647、/v1/room 10 间空教室,全部 ok: true。
    六、还没解决的,比已解决的更值得写
    登录链路没写。 不是不会写,是它绕不开一个取舍:要让学生在手机上完成学校 CAS 登录(含图形验证码),要么反向代理让密码经过我的服务器(但不落库),要么服务端代填验证码。两条路都和 v1「绝不碰密码」的定位冲突。这个决定我不能替用户做。
    还有一个前置验证项:云服务器能不能访问学校 WebVPN。 校外 IP 有时会被限制,如果不行,服务只能放在校内机器上 —— 这个不先验证,买了服务器也是白买。
    以及一个产品层面的判断:用户选的是"普通豆包对话",它没有调用外部接口的能力,所以"在豆包里问一句就出课表"做不到,不管后端多完善。要么去扣子/豆包智能体平台建个智能体(免费,导入 openapi.yaml 配成插件),要么退一步做 H5 页面。
    我的建议是先做 0 成本的验证:建个智能体发给两三个同学试用,看他们愿不愿意多走这一步。别先花钱买服务器,再发现没人用。
    复盘:三条能带走的经验
  6. 报错的位置 ≠ 根因的位置。 Edge 报"另一个会话占用",根因是十行外 cookie 没落盘。遇到诡异报错,先把因果链从头到尾画出来,再动手。
  7. "网络不通"是个结论,不是一个事实。 curl 不通的域名 PowerShell 可能通;git clone 不通的时候 api.github.com 可能通。下结论前换客户端、换通道多试一次。
  8. 跨平台 bug 几乎都藏在"隐形假设"里。 spawn('npm') 假设了 PATH 里有 npm 而不是 npm.cmd;{ ...process.env } 假设了环境变量大小写敏感。这两类问题在 CI 上永远测不出来,只有在真实用户的机器上才炸。
    还有一条更现实的:先验证有人愿意用,再投入成本。 技术跑通了不等于产品成立。
posted on 2026-10-28 21:53  chenyun_922  阅读(5)  评论(0)    收藏  举报