NocoDB 的网页适合人工录入和查看数据,API 则适合让程序自动读写数据。本文以一个简单的 tasks 表开始,依次完成查询、新增、修改和删除,最后把这些操作组合成实际使用过的“洛谷比赛同步”逻辑。
本文使用 NocoDB v2 Data API 和 Node.js 20+ 自带的 fetch。完整请求地址具有下面的形式:
1http://localhost:8080/api/v2/tables/<TABLE_ID>/records
只需要先记住三个值:
| 配置 | 示例 | 作用 |
|---|---|---|
NOCODB_BASE_URL | http://localhost:8080 | NocoDB 服务地址 |
NOCODB_TABLE_ID | mxxxxxxxxxxxxxx | 要操作的表 |
NOCODB_TOKEN | nc_pat_xxxxxxxxx | 证明程序有权访问这张表 |
准备 tasks 表和访问凭据
在 NocoDB 中新建一张 tasks 表。Id 是 NocoDB 自动创建的系统字段,再添加下面四列:
| 字段 | NocoDB 类型 | 示例 |
|---|---|---|
Title | 单行文本 | 整理比赛数据 |
Done | 勾选 | false |
Priority | 数字 | 3 |
DueAt | 日期时间 | 2026-08-30T12:00:00.000Z |
打开这张表的 Data APIs 或 API Snippets 页面,从生成的请求地址中复制 Table ID。不要把表在网页中显示的名字当成 Table ID;它通常是一串类似 mxxxxxxxxxxxxxx 的内部标识。
然后在 NocoDB 的账户设置中创建 API Token。菜单名称可能随版本变化,一般位于 Account Settings -> Tokens。Token 会继承创建者的权限:本文需要写入数据,因此应使用只对目标 Base 有必要写权限的独立账号,不要使用 Owner 账号的 Token。只读程序则给 Viewer 权限即可。
把配置放进 .env:
1NOCODB_BASE_URL=http://localhost:8080
2NOCODB_TABLE_ID=mxxxxxxxxxxxxxx
3NOCODB_TOKEN=nc_pat_xxxxxxxxxxxxxxxxx
同时把 .env 加入 .gitignore,不要把真实 Token 提交到 Git:
1.env
不要把 xc-token 写入浏览器端 JavaScript。浏览器会把它暴露给每一位访问者。
第一次查询:先看见完整请求
先不封装函数,直接写一个最小的 list.js:
1async function main() {
2 const baseUrl = process.env.NOCODB_BASE_URL;
3 const tableId = process.env.NOCODB_TABLE_ID;
4 const token = process.env.NOCODB_TOKEN;
5
6 const url = new URL(`/api/v2/tables/${tableId}/records`, baseUrl);
7 url.searchParams.set('fields', 'Id,Title,Done,Priority,DueAt');
8 url.searchParams.set('limit', '10');
9
10 const response = await fetch(url, {
11 headers: {
12 'xc-token': token,
13 },
14 });
15
16 if (!response.ok) {
17 throw new Error(`NocoDB 返回 ${response.status}: ${await response.text()}`);
18 }
19
20 const result = await response.json();
21 console.table(result.list);
22 console.log('分页信息:', result.pageInfo);
23}
24
25main().catch((error) => {
26 console.error(error);
27 process.exitCode = 1;
28});
运行它:
1node --env-file=.env list.js
这里最值得注意的是返回值。查询接口返回的不是记录数组本身,而是一个对象:
1{
2 "list": [
3 {
4 "Id": 1,
5 "Title": "整理比赛数据",
6 "Done": false,
7 "Priority": 3,
8 "DueAt": "2026-08-30 20:00:00+08:00"
9 }
10 ],
11 "pageInfo": {
12 "totalRows": 1,
13 "page": 1,
14 "pageSize": 10,
15 "isFirstPage": true,
16 "isLastPage": true
17 }
18}
result.list才是记录数组。result.pageInfo描述分页状态。fields限制返回列,既减少传输数据,也让代码明确依赖哪些字段。xc-token是 NocoDB v2 API 使用的认证请求头。
fetch 遇到 401、404 或 500 时不会自动抛出异常,因此每次都要检查 response.ok。这是后面封装公共函数的主要原因。
封装公共 NocoDB 客户端
新增 nocodb-client.js,集中处理地址、Token、JSON 和错误。后面的业务脚本只需表达“我要查什么”或“我要写什么”。
1const requiredEnv = [
2 'NOCODB_BASE_URL',
3 'NOCODB_TABLE_ID',
4 'NOCODB_TOKEN',
5];
6
7for (const name of requiredEnv) {
8 if (!process.env[name]) {
9 throw new Error(`缺少环境变量 ${name}`);
10 }
11}
12
13const baseUrl = process.env.NOCODB_BASE_URL;
14const tableId = process.env.NOCODB_TABLE_ID;
15const token = process.env.NOCODB_TOKEN;
16const recordsUrl = new URL(
17 `/api/v2/tables/${encodeURIComponent(tableId)}/records`,
18 baseUrl,
19);
20
21async function request({ method = 'GET', query = {}, body } = {}) {
22 const url = new URL(recordsUrl);
23
24 for (const [name, value] of Object.entries(query)) {
25 if (value !== undefined && value !== null) {
26 url.searchParams.set(name, String(value));
27 }
28 }
29
30 const options = {
31 method,
32 headers: {
33 'xc-token': token,
34 },
35 };
36
37 if (body !== undefined) {
38 options.headers['Content-Type'] = 'application/json';
39 options.body = JSON.stringify(body);
40 }
41
42 let response;
43 try {
44 response = await fetch(url, options);
45 } catch (cause) {
46 throw new Error(`无法连接 NocoDB:${cause.message}`, { cause });
47 }
48
49 const text = await response.text();
50 let data = null;
51
52 if (text) {
53 try {
54 data = JSON.parse(text);
55 } catch {
56 data = text;
57 }
58 }
59
60 if (!response.ok) {
61 const detail = typeof data === 'string' ? data : JSON.stringify(data);
62 throw new Error(`NocoDB ${method} ${response.status}: ${detail}`);
63 }
64
65 return data;
66}
67
68function listRecords(query) {
69 return request({ query });
70}
71
72function createRecord(record) {
73 return request({ method: 'POST', body: record });
74}
75
76function updateRecord(record) {
77 return request({ method: 'PATCH', body: record });
78}
79
80function deleteRecord(id) {
81 return request({ method: 'DELETE', body: { Id: id } });
82}
83
84module.exports = {
85 listRecords,
86 createRecord,
87 updateRecord,
88 deleteRecord,
89};
这段封装做了四件事:
- 启动时检查三个必要配置,避免把
undefined拼进请求地址。 - 通过
URLSearchParams自动编码查询参数。 - 写入时把 JavaScript 对象转换为 JSON。
- 无论 NocoDB 返回 JSON 还是纯文本错误,都尽量保留详细信息。
查询:过滤、排序和分页
把 list.js 改成使用公共客户端:
1const { listRecords } = require('./nocodb-client');
2
3async function main() {
4 const result = await listRecords({
5 fields: 'Id,Title,Done,Priority,DueAt',
6 where: '(Done,eq,false)',
7 sort: '-Priority,DueAt',
8 limit: 20,
9 offset: 0,
10 });
11
12 console.table(result.list);
13 console.log('总记录数:', result.pageInfo.totalRows);
14}
15
16main().catch((error) => {
17 console.error(error);
18 process.exitCode = 1;
19});
查询参数可以组合使用:
| 参数 | 示例 | 含义 |
|---|---|---|
fields | Id,Title,Done | 只返回指定字段 |
where | (Done,eq,false) | 只查询未完成任务 |
sort | -Priority,DueAt | 优先级降序,再按截止时间升序 |
limit | 20 | 本次最多返回 20 条 |
offset | 0 | 跳过前面的记录数 |
where 是 NocoDB 的过滤表达式,基本形状是:
1(字段,比较操作,值)
例如:
1(Priority,gte,3)
2(Done,eq,false)
3((Done,eq,false)~and(Priority,gte,3))
不要再对 where 手动调用 encodeURIComponent()。客户端中的 url.searchParams.set() 已经完成 URL 编码,再编码一次会导致 NocoDB 无法识别条件。
新增记录
create.js 创建一条任务:
1const { createRecord } = require('./nocodb-client');
2
3async function main() {
4 const created = await createRecord({
5 Title: '整理洛谷比赛数据',
6 Done: false,
7 Priority: 3,
8 DueAt: new Date('2026-08-30T20:00:00+08:00').toISOString(),
9 });
10
11 console.log('创建成功:', created);
12}
13
14main().catch((error) => {
15 console.error(error);
16 process.exitCode = 1;
17});
1node --env-file=.env create.js
传给 createRecord() 的对象,其属性名必须与 NocoDB 的字段名一致。字段类型也要匹配:勾选字段使用布尔值 true/false,数字字段不要传成带文字的字符串。
日期使用带时区的输入,再调用 toISOString() 转为标准 UTC 时间。上面的 20:00 +08:00 会被发送为 12:00Z,两者表示同一个时刻,并不是时间少了八小时。
修改记录
NocoDB 使用系统字段 Id 确定要修改哪条记录。update.js 从命令行接收这个 Id:
1const { updateRecord } = require('./nocodb-client');
2
3async function main() {
4 const id = Number(process.argv[2]);
5 if (!Number.isInteger(id)) {
6 throw new Error('请提供要修改的数字 Id,例如:node update.js 12');
7 }
8
9 const updated = await updateRecord({
10 Id: id,
11 Done: true,
12 });
13
14 console.log('修改成功:', updated);
15}
16
17main().catch((error) => {
18 console.error(error);
19 process.exitCode = 1;
20});
1node --env-file=.env update.js 12
PATCH 只修改传入的字段。这里不会改变 Title、Priority 和 DueAt。注意,Id 是 NocoDB 的记录主键,不是后面比赛数据里的业务字段 cid。
删除记录
delete.js 同样从命令行接收 Id:
1const { deleteRecord } = require('./nocodb-client');
2
3async function main() {
4 const id = Number(process.argv[2]);
5 if (!Number.isInteger(id)) {
6 throw new Error('请提供要删除的数字 Id,例如:node delete.js 12');
7 }
8
9 await deleteRecord(id);
10 console.log(`已删除记录 ${id}`);
11}
12
13main().catch((error) => {
14 console.error(error);
15 process.exitCode = 1;
16});
1node --env-file=.env delete.js 12
删除通常不可恢复。先用查询确认 Id,并且只用自己刚创建的测试记录练习。如果你的 NocoDB 版本在 API Snippets 中给出的 DELETE 请求体是数组,就把公共客户端中的删除函数改为 body: [{ Id: id }];以当前服务器自动生成的 API 文档为准。
到这里,四种操作和请求方法的对应关系已经很清楚:
| 目的 | 客户端函数 | 请求方法 |
|---|---|---|
| 查询 | listRecords() | GET |
| 新增 | createRecord() | POST |
| 修改 | updateRecord() | PATCH |
| 删除 | deleteRecord() | DELETE |
实战:按 cid 同步洛谷比赛
现在换成真实场景。NocoDB 的 contest 表包含:
| 字段 | 类型 | 用途 |
|---|---|---|
cid | 数字,唯一 | 洛谷比赛 ID,也是业务唯一键 |
name | 单行文本 | 比赛名称 |
mode | 单行文本或单选 | 赛制 |
start、end | 日期时间 | 开始和结束时间 |
rated | 勾选 | 是否计分 |
host | 单行文本 | 主办方 |
url | URL | 比赛链接 |
notified | 勾选 | 是否已经发送提醒 |
这一次把 .env 中的 NOCODB_TABLE_ID 换成 contest 表的 Table ID。
洛谷接口中的 id 是比赛编号,NocoDB 创建记录后还有自己的 Id。同步时不能直接拿 cid 做 PATCH,因为 NocoDB 更新接口需要的是 Id。正确过程是:
1用 cid 查询 -> 找到 NocoDB Id -> 有则 PATCH,无则 POST
这种“存在就更新,不存在就新增”的操作称为 upsert。新增 upsert-contest.js:
1const {
2 listRecords,
3 createRecord,
4 updateRecord,
5} = require('./nocodb-client');
6
7function toRow(contest) {
8 return {
9 cid: Number(contest.id),
10 name: contest.name,
11 mode: contest.mode,
12 start: new Date(contest.start * 1000).toISOString(),
13 end: new Date(contest.end * 1000).toISOString(),
14 rated: Boolean(contest.rated),
15 host: contest.host ?? '',
16 url: contest.url,
17 };
18}
19
20async function upsertContest(contest) {
21 const cid = Number(contest.id);
22 if (!Number.isInteger(cid)) {
23 throw new Error(`无效的比赛 id:${contest.id}`);
24 }
25
26 const result = await listRecords({
27 where: `(cid,eq,${cid})`,
28 fields: 'Id',
29 limit: 1,
30 });
31
32 const row = toRow(contest);
33 const existing = result.list[0];
34
35 if (existing) {
36 await updateRecord({ Id: existing.Id, ...row });
37 return 'updated';
38 }
39
40 await createRecord({ ...row, notified: false });
41 return 'created';
42}
43
44async function main() {
45 const contest = {
46 id: 352393,
47 name: '示例比赛',
48 mode: 'OI',
49 start: 1788062400,
50 end: 1788073200,
51 rated: true,
52 host: '示例主办方',
53 url: 'https://www.luogu.com.cn/contest/352393',
54 };
55
56 const action = await upsertContest(contest);
57 console.log(`比赛 ${contest.id}: ${action}`);
58}
59
60main().catch((error) => {
61 console.error(error);
62 process.exitCode = 1;
63});
这里有两个容易忽略的细节:
- 更新时没有传
notified。已经提醒过的比赛再次同步时,不能把它重置为false,否则会重复发通知。 start和end原本是 Unix 秒,而 JavaScript 的Date接收毫秒,所以必须先乘以1000。
应当在 NocoDB 中为 cid 添加唯一约束。当前实现是“先查再写”,两个同步任务同时运行时,可能都查到不存在并尝试新增;唯一约束至少能阻止重复数据。单实例定时任务通常足够,存在并发写入时还要捕获唯一冲突后重试更新。
按日期查询比赛
例如查询 2026 年 8 月 26 日 17:00(东八区)之后结束的比赛:
1const { listRecords } = require('./nocodb-client');
2
3async function main() {
4 const result = await listRecords({
5 fields: 'cid,name,start,end,url',
6 where: '(end,ge,exactDate,2026-08-26 17:00:00+08:00)',
7 sort: 'end',
8 limit: 50,
9 });
10
11 console.table(result.list);
12}
13
14main().catch((error) => {
15 console.error(error);
16 process.exitCode = 1;
17});
时区必须明确。2026-08-26 17:00:00+08:00 表示北京时间,2026-08-26T09:00:00.000Z 表示同一个时刻。不要传没有 +08:00 或 Z 的模糊时间,否则脚本所在机器、NocoDB 和浏览器可能做出不同解释。
常见错误
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
401 Unauthorized | Token 错误或已失效 | 重新创建 Token,检查 .env 是否加载 |
403 Forbidden | Token 对目标 Base 没有权限 | 给服务账号最小必要权限 |
404 Not Found | Table ID、地址或 API 版本错误 | 从该表的 API Snippets 重新复制地址 |
422 Unprocessable Entity | 字段名、类型或过滤语法错误 | 对照表结构和响应正文检查请求数据 |
fetch failed | NocoDB 不可达 | 检查端口、防火墙、容器映射和局域网地址 |
| 查询结果总是空 | 把 Id、cid 混用,或时间时区错误 | 打印最终 URL,并在 NocoDB 中核对原始值 |
调试时不要只输出“请求失败”。公共客户端会保留状态码和响应正文,这些信息通常已经明确指出哪个字段或条件有问题。同时不要把完整请求头写入日志,因为其中包含 Token。
为什么 Node.js 能 fetch,n8n 却可能报错
Node.js 20+ 在普通脚本中提供全局 fetch,所以本文代码无需安装 HTTP 库。但 n8n 的 Code 节点运行在受限制的执行环境中,是否提供全局 fetch 取决于 n8n 版本和任务运行器配置,因此可能出现:
1fetch is not defined
这不代表 NocoDB API 有问题,也不代表 Node.js 示例有问题,只是两段代码的运行环境不同。在 n8n 中应使用该版本明确提供的 HTTP Request 节点或请求辅助函数;不要因为普通 Node.js 支持 fetch,就假定所有 JavaScript 沙箱也支持。
最终文件结构
完成后,示例代码可以按下面的方式保存:
1nocodb-demo/
2├── .env
3├── .gitignore
4├── nocodb-client.js
5├── list.js
6├── create.js
7├── update.js
8├── delete.js
9└── upsert-contest.js
学习 NocoDB API 的关键不是记住每个参数,而是建立一条稳定思路:先从表的 API Snippets 确认地址和字段,再用一个最小 GET 验证连接,把认证和错误处理封装起来,最后让业务代码只负责数据规则。对于比赛同步,真正的业务规则就是 cid 唯一、时间转换正确,并且更新时保留 notified。