冬日绘板 2026 API 官方文档及 JS/Python 实现
Federico2903 · · 个人记录
最后更新:2025/10/14。
欢迎参与 LGS Paintboard 2026!
API 地址:https://paintboard.luogu.me。
本文中的 Token 指的是 PaintKey 而非用于登录保存站的 Token。
HTTP API
获取版面
| 类型 | 操作 |
|---|---|
| 请求 | GET /api/paintboard/getboard |
| 响应主体 | application/octet-stream |
Javascript 解码响应内容:
for (let y = 0; y < 600; y++) {
for (let x = 0; x < 1000; x++) {
// (y, x) 的颜色编号:
const color = ('00000' +
(
byteArray[y * 1000 * 3 + x * 3] * 0x10000 +
byteArray[y * 1000 * 3 + x * 3 + 1] * 0x100 +
byteArray[y * 1000 * 3 + x * 3 + 2]
).toString(16)
).substr(-6);
}
}
获取 Token
| 类型 | 操作 |
|---|---|
| 请求 | POST /api/auth/gettoken |
| 参数 | application/json({ uid: number, access_key: string }) |
| 响应主体 | application/json(DataResponse<Token>) |
uid:需要获取 Token 的 UID。
access_key:你的 AccessKey。
Token:{ token?: string, errorType?: string }。
Token.errorType:
INVALID_ACCESS_KEYAccessKey 无效。UID_MISMATCHUID 不匹配。SERVER_ERROR服务器错误。BAD_REQUEST请求格式错误。
WebSocket API
绘版后端已经改用粘性发包,其机制即为将所有的二进制信息拼接。
可以参见后文“粘包发送”。
下方介绍的均为一个包单元,请自行拆包。
操作码的定义:每个包的第一个字节的内容。
服务端侧(S2C)
Heartbeat (Ping)
| 数据位置 | 操作码 |
|---|---|
| 数据内容 | 0xfc |
当你收到该操作时,应当立即应答,参见“客户端侧 Heartbeat (Pong)”。
绘画消息
| 数据位置 | 操作码 | Uint16 | Uint16 | Uint8 | Uint8 | Uint8 |
|---|---|---|---|---|---|---|
| 数据内容 | 0xfa | 事件 |
事件 |
新的 |
新的 |
新的 |
绘画结果
| 数据位置 | 操作码 | Uint32 | Uint8 |
|---|---|---|---|
| 数据内容 | 0xff | 绘图识别码 | 状态码 |
状态码解释:
0xef成功。0xee正在冷却。0xedToken 无效。0xec请求格式错误。0xeb无权限。0xea服务器错误。
客户端侧(C2S)
Heartbeat (Pong)
| 数据位置 | 操作码 |
|---|---|
| 数据内容 | 0xfb |
当收到“服务端侧 Heartbeat (Ping)”时立即进行此操作。
绘画操作
前
| 数据位置 | 操作码 | Uint16 | Uint16 | Uint8 | Uint8 | Uint8 |
|---|---|---|---|---|---|---|
| 数据内容 | 0xfe | 绘画的 |
绘画的 |
后
| 数据位置 | Uint24 (Uint8 * 3) | Uint128 | Uint32 |
|---|---|---|---|
| 数据内容 | Token 的 uid,拆分成三个 Uint8 发送 | Token,本质是 UUID | 绘图识别码 |
理论上识别码需要唯一,至少在服务器返回信息前是唯一的。
API 限制
服务端侧作出如下限制:
- 每秒钟每个 WebSocket 连接可以发送至多
256 个包。 - 每个 IP 地址最多可以建立
3 个读写 WebSocket 连接。 - 每个 IP 地址最多可以建立
50 个只读 WebSocket 连接(WS 连接地址加后缀?readonly=1)。 - 每个 IP 地址最多可以建立
5 个只写 WebSocket 连接(WS 连接地址加后缀?writeonly=1)。 - 每个包大小不超过
32 KB,否则会被断开连接。
违反上述限制可能会导致 IP 被暂时封禁,WebSocket 尝试 Upgrade 时返回状态码
切断连接
服务端可能会出于某些原因主动切断你的 WebSocket 连接,并返回状态码和原因。
状态码解释如下:
错误的心跳,在服务端未请求心跳时进行心跳。
-
\text{Protocol violation: unknown packet type} 错误的包单元操作码。
-
\text{Protocol violation: duplicate ping state} 服务端在上一个心跳超时时长中请求了下一个心跳,一般不会出现此错误,出现了此错误请上报。
NodeJS 实现
WebSocket 连接
NodeJS 中存在 WebSocket 对象,从 ws 中导入即可。
通过将函数绑定到 onopen,onclose,onerror 和 onmessage 上即可进行事件处理。
import WebSocket from 'ws';
const WS_URL = "wss://paintboard.luogu.me/api/paintboard/ws";
ws = new WebSocket(WS_URL);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
console.log("WebSocket 连接已打开。");
};
ws.onmessage = (event) => {
const buffer = event.data;
const dataView = new DataView(buffer);
// 处理你的数据
// 我建议使用 DataView 处理
};
ws.onerror = (err) => {
console.error(`WebSocket 出错:${err.message}。`);
};
ws.onclose = (err) => {
const reason = err.reason ? err.reason : "Unknown";
console.log(`WebSocket 已经关闭 (${err.code}: ${reason})。`);
};
数据处理
按照上文的操作码对包单元分别处理:
let offset = 0;
while (offset < buffer.byteLength) {
const type = dataView.getUint8(offset);
offset += 1;
switch (type) {
case 0xfa: {
const x = dataView.getUint16(offset, true);
const y = dataView.getUint16(offset + 2, true);
const colorR = dataView.getUint8(offset + 4);
const colorG = dataView.getUint8(offset + 5);
const colorB = dataView.getUint8(offset + 6);
offset += 7;
// 此时在 (x, y) 进行了一次颜色为 (R, G, B) 的绘画
break;
}
case 0xfc: {
ws.send(new Uint8Array([0xfb]));
break;
}
case 0xff: {
const id = dataView.getUint32(offset, true);
const code = dataView.getUint8(offset + 4);
offset += 5;
// 绘画任务返回
// 可以使用存储回调函数的方式实现
break;
}
default:
console.log(`未知的消息类型:${type}`);
}
}
粘包发送
生成粘包的方式非常简单,直接拼接即可。
为了优化性能,我们使用一个队列把所有的包单元存储起来一起拼接。
let chunks = [];
let totalSize = 0;
function appendData(paintData) {
chunks.push(paintData);
totalSize += paintData.length;
}
function getMergedData() {
let result = new Uint8Array(totalSize);
let offset = 0;
for (let chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
totalSize = 0;
chunks = [];
return result;
}
绘画操作
所有的数字都用小端序存储。
请不要忘记这一点,可以参考下方的 uintToUint8Array。
切记使用粘包发送,否则可能会被暂时封禁 IP。
let paintId = 0;
function uintToUint8Array(uint, bytes) {
const array = new Uint8Array(bytes);
for (let i = 0; i < bytes; i++) {
array[i] = uint & 0xff;
uint = uint >> 8;
}
return array;
}
async function paint(uid, token, r, g, b, nowX, nowY) {
const id = (paintId++) % 4294967296;
paintCnt++;
const tokenBytes = new Uint8Array(16);
token.replace(/-/g, '').match(/.{2}/g).map((byte, i) =>
tokenBytes[i] = parseInt(byte, 16));
const paintData = new Uint8Array([
0xfe,
...uintToUint8Array(nowX, 2),
...uintToUint8Array(nowY, 2),
r, g, b,
...uintToUint8Array(uid, 3),
...tokenBytes,
...uintToUint8Array(id, 4)
]);
appendData(paintData);
}
setInterval(() => {
// 检查是否有包需要发送,以及 WebSocket 连接是否已打开
if (chunks.length > 0 && ws.readyState === WebSocket.OPEN) {
ws.send(getMergedData());
}
}, 20);
// 20 毫秒发送一次,每秒 50 个包
图像处理
有大量的第三方库可以用来处理图像,我使用的是 sharp。
直接从 sharp 中导入 sharp 即可:
import sharp from 'sharp';
读取图像
调用 sharp 的构造函数并传入地址即可:
const image = sharp('/path/to/image');
这样就可以创建一个包含图像数据的 sharp 对象。
图像元数据
调用异步成员函数 metadata 可以获取图像的元数据,包括但不限于宽高,通道数。
const metadata = await image.metadata();
const { width, height, channels } = metadata;
// width 表示图像的宽,height 表示图像的高,channels 是图像的通道数
请注意有的图像是四通道(RGBA),而有的图像是三通道(RGB),如果通道数处理不善会出现像素错误。
图像像素数据
调用异步函数 raw().toBuffer 可以获取图像的像素信息,其返回一个数组。
每相邻的通道数个元素表示一个像素点的信息。
例如四通道的图片的四维信息(RGBA)分别在
我们只需要提取 RGB 信息即可:
const pixels = await image.raw().toBuffer();
const pixelData = [];
for (let i = 0; i < pixels.length; i += channels) {
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
pixelData.push({ r, g, b });
}
Python 实现
代码地址,感谢 @luojien。