Porting from pkucli
pkucli is the protocol reference for PKU services. How to port endpoints, login steps and quirks from its Rust crates into a plugin, leave sign-in to the app, and credit the source file.
pkuinfo/pkucli is a set of command-line tools for PKU web
services, written in Rust. Its authors have already worked out the hard parts of each service:
which endpoints exist, which sign-in steps the service needs, which headers and parameters it
requires, and how its HTML is structured.
When you write a plugin for a service that pkucli already supports, port pkucli's behaviour instead of working it out again, and cite the source file in a comment. This page shows where to find things in pkucli, which parts belong in the plugin and which stay in the app, and an example.
Most services sign in through IAAA, PKU's central sign-in system (iaaa.pku.edu.cn). After IAAA, each service usually needs one or more single sign-on (SSO) steps of its own. We call each such step a login hop.
Where things are
| pkucli crate | Service | In PKU harness |
|---|---|---|
common | IAAA login (password, QR code), TOTP codes, captcha, sessions, credentials | The app (src/core/campus); not in plugins |
campuscard | Campus card (bdcard) | wallet plugin, card campus service |
treehole | Treehole (树洞) | treehole plugin, treehole service |
course | Blackboard (教学网) | courses plugin, blackboard service |
elective | Course selection | elective plugin, elective service |
cwfw, cwpay | Finance, finance payments | finance plugin, cwfw / cwpay services |
bdkj | Room booking | room-booking plugin, bdkj service |
portal | Portal | The portal's SSO hop, inside the app |
claspider | Course recordings (智云课堂) | Planned |
info-spider, info-auth | WeChat articles | notices plugin reads single articles (cleaning ported from info-spider) |
TOTP (time-based one-time password) is the six-digit code an authenticator app shows. Inside a
crate, api.rs (or client.rs) holds the endpoints and response types, login.rs holds the
service's own login hops, and commands.rs holds the operations a user runs.
What moves where
Each file of a pkucli crate maps to a place in PKU harness. Sign-in moves into the app, and everything else moves into the plugin:
pkucli crate PKU harness
───────────────────────────── ──────────────────────────────────────────────
login.rs (IAAA → service SSO) app: a campus service adapter (harness-owned)
client.rs (headers, base URL) plugin: the `url`/`headers` of services.campus.request
api.rs (endpoints, types) plugin: src/api.ts + zod/TS types
commands.rs / display.rs plugin: tools (data, not formatted text)- Sign-in stays in the app. A plugin never implements IAAA or a service's SSO hops, so it never
handles a password. Today the app signs in to
card,its,treehole,blackboard,bdkj,cwfw,cwpay,elective,libraryandlibrary-space. If the service you need is not in that list, open an issue inpku-harness. A new service needs a new adapter insrc/core/campus/adapters/. - Requests go through
services.campus.request(service, req). Give paths relative to the service's host. The app adds the cookies or tokens. - Public pages that need no login can use plain
fetchwithrequires: ['network']. Copy pkucli's headers, such as a desktop user agent andaccept-language: zh-CN, because some campus servers reject requests without them. - Parse into data. pkucli's
display.rsformats output for a terminal. A tool returns structured JSON instead, because its reader is the model or a screen.
Example
Here is the campus card query in pkucli, written in Rust:
/// 查询校园卡信息
pub async fn query_card(&self) -> Result<CardQueryData> {
let url = format!("{CARD_BASE}/berserker-app/ykt/tsm/queryCard?synAccessSource=h5");
let resp = self.http.get(&url).send().await.context("查询校园卡失败")?;
let api_resp: ApiResp<CardQueryData> = parse_response(resp).await?;
check_resp(api_resp)
}The same request in a plugin, written in TypeScript. The comment at the top credits the pkucli file
and records two quirks: amounts are in fen (0.01 yuan), and every call carries
synAccessSource=h5.
/**
* Campus card (bdcard.pku.edu.cn) response handling, ported from pkucli
* crates/campuscard/src/api.rs. Amounts are in fen; every call carries synAccessSource=h5.
*/
import type { HostServices } from '@pku-harness/sdk';
type Services = Pick<HostServices, 'campus'>;
const get = (services: Services, url: string) => services.campus.request('card', { url });
export async function cardAccount(services: Services) {
const data = unwrap<{ card: CardInfo[] }>(await get(services, '/berserker-app/ykt/tsm/queryCard?synAccessSource=h5'));
const account = data.card[0]?.account;
if (!account) throw new Error('校园卡缺少账户信息');
return account;
}Second factors
Some services ask for an extra check, such as an SMS code. pkucli asks for the code in the
terminal, which a plugin can't do. In a plugin, a check at the service level becomes a tool result
such as { status: 'verification_required', methods: ['sms'], message }, together with a tool that
sends or redeems the code. Treehole's SMS check (error 40002) and its course token (error 40077)
work this way. The plugin's screen can also offer services.sms.awaitCode(), which reads the code
from the SMS.
Second factors at the IAAA level are handled by the app, not by plugins.
Rules
- MUST cite the pkucli file you ported from in a comment at the top of the module.
- MUST keep sign-in in the app. Plugins only make requests.
- SHOULD port each parser together with a test and a fixture made from a real, anonymised response.
- SHOULD fix bugs in both places. If you find a quirk pkucli doesn't handle, open an issue or a pull request in pkucli too.
Testing
How to test each kind of plugin. Replay recorded responses for plugin documents, unit-test parsers and pure logic with bun test, run the assistant end to end with the scripted mock model, and try remote plugins with the demo-canteen example.
Publishing
How a remote or bundled plugin reaches users. Remote plugins are listed in the marketplace registry. Bundled plugins are merged into pku-harness-plugins and reach phones as a signed over-the-air update or with the next app release.