用 Gemini 3 实现了飞书日历节假日同步

背景

飞书日历一直以来有一个问题(其实有好多问题),例如不支持显示法定节假日,这样就会导致我在安排时间表的时候必须参考另外的日历——但飞书日历本身就是个日历,这样感觉很不优雅,直到我看到了这篇文章。但是它是采用的飞书内置的流程中心(需要付费),对于白嫖用户来说并不友好。幸运的是飞书官方支持日历的API,所以我和 Gemini 一拍即合,打算尝试一下能不能只使用 AI Vibe Coding 来完成这个功能,最后我和 Gemini 总共花了 1h42m 就完成了这个项目的开发和调试(其实主要还是调试比较麻烦,因为时区问题导致了很多bug,虽然我觉得我手工写可能用不了一个小时)。

先附上效果图:

一些小小的技术细节

我是采用的 CNB 直接进行开发,代码完全没有落地,并且配了一个 .cnb.yml 让脚本每个月自动执行,这样就可以保持日历的更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
main:
# 每月 1 日 7:30 自动执行
"crontab: 30 7 1 * *":
app:
git:
enable: true
submodules: true
stages:
- name: npm start
image: node:24
script: |
npm install
npm start

需要事先创建一个应用,并配置好 app_idapp_secret,且给这个应用开通日历相关的权限。

权限管理

在执行完成后,理论上飞书就可以在日历的搜索中找到这个日历并直接订阅,你如果是组织的管理员还可以设置为全员日历,这样就可以优雅的以边车模式把法定节假日装载到你的飞书日历了。

代码

我是在 node:24 的环境中运行的:

1
2
npm init -y
npm i axios dayis js-yaml node-ical

config.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 飞书应用配置
feishu:
app_id: "cli_xxx"
app_secret: "xxx"

# 日历配置
calendar:
name: "中国法定节假日" # 想要创建/查找的日历名称
color: -1 # 日历颜色,-1为默认
summary_prefix: "" # 可选:给日程加前缀,如 "[休]"

# 数据源
source:
icloud_url: "https://calendars.icloud.com/holiday/cn_zh.ics"

index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
import fs from 'node:fs';
import yaml from 'js-yaml';
import axios from 'axios';
import ical from 'node-ical';
import dayjs from 'dayjs';

// 读取配置
const config = yaml.load(fs.readFileSync('./config.yaml', 'utf8'));

const FEISHU_HOST = 'https://open.feishu.cn/open-apis';
let TENANT_ACCESS_TOKEN = '';

// 1. 获取 Token
async function getAccessToken() {
try {
const res = await axios.post(`${FEISHU_HOST}/auth/v3/tenant_access_token/internal`, {
app_id: config.feishu.app_id,
app_secret: config.feishu.app_secret
});
if (res.data.code !== 0) throw new Error(res.data.msg);
TENANT_ACCESS_TOKEN = res.data.tenant_access_token;
console.log('✅ 获取 Access Token 成功');
} catch (e) {
console.error('❌ 获取 Token 失败:', e.message);
process.exit(1);
}
}

// 2. 获取或创建日历
async function getOrCreateCalendar() {
const headers = { Authorization: `Bearer ${TENANT_ACCESS_TOKEN}` };
let pageToken = '';
let foundCal = null;

do {
const res = await axios.get(`${FEISHU_HOST}/calendar/v4/calendars`, {
headers,
params: { page_size: 500, page_token: pageToken }
});
if (res.data.code !== 0) throw new Error(res.data.msg);
foundCal = (res.data.data.calendar_list || []).find(c => c.summary === config.calendar.name);
pageToken = res.data.data.page_token;
} while (pageToken && !foundCal);

if (foundCal) {
console.log(`✅ 找到日历: ${foundCal.summary}`);
return foundCal.calendar_id;
}

console.log(`ℹ️ 创建新日历: ${config.calendar.name}...`);
const createRes = await axios.post(`${FEISHU_HOST}/calendar/v4/calendars`, {
summary: config.calendar.name,
description: "自动同步的节假日日历",
permissions: "public",
color: config.calendar.color,
summary_alias: config.calendar.name
}, { headers });
return createRes.data.data.calendar.calendar_id;
}

// 3. 获取 iCloud 数据 (已修复日期范围问题)
async function fetchICloudEvents() {
console.log('⬇️ 正在下载 iCloud 数据...');
const events = await ical.async.fromURL(config.source.icloud_url);

const validEvents = [];
const now = dayjs().startOf('day');
const oneYearLater = now.add(1, 'year').endOf('day');

for (const k in events) {
const ev = events[k];
if (ev.type !== 'VEVENT') continue;

// 处理标题
let summaryText = '';
if (ev.summary && typeof ev.summary === 'object' && ev.summary.val) {
summaryText = ev.summary.val;
} else {
summaryText = String(ev.summary || '');
}

const startDate = dayjs(ev.start);

let endDate;
if (ev.end) {
const tempEnd = dayjs(ev.end);
// ics 协议中,全天日程 end 是独占的(即次日零点)。
// 飞书全天日程需要的是包含的结束日期。
// 所以,如果 end > start,我们需要减去 1 天。
if (tempEnd.isAfter(startDate, 'day')) {
endDate = tempEnd.subtract(1, 'day');
} else {
// 如果 end 和 start 是同一天(极少见),或者数据异常,保持原样
endDate = tempEnd;
}
} else {
// 如果没有 end,默认为当天
endDate = startDate;
}

if (startDate.isAfter(now) && startDate.isBefore(oneYearLater)) {
validEvents.push({
summary: summaryText,
startDate: startDate.format('YYYY-MM-DD'),
endDate: endDate.format('YYYY-MM-DD'),
uid: ev.uid
});
}
}
console.log(`✅ 解析到源数据: ${validEvents.length} 条`);
return validEvents;
}

// 4. 获取飞书现有数据
async function fetchFeishuEvents(calendarId) {
const headers = { Authorization: `Bearer ${TENANT_ACCESS_TOKEN}` };
const now = dayjs().unix();
const oneYearLater = dayjs().add(1, 'year').unix();

const res = await axios.get(`${FEISHU_HOST}/calendar/v4/calendars/${calendarId}/events`, {
headers,
params: { start_time: String(now), end_time: String(oneYearLater), page_size: 500 }
});

return (res.data.data.items || []).map(e => ({
...e,
event_id: e.event_id,
summary: e.summary,
startDate: e.start_time.date,
endDate: e.end_time.date
}));
}

async function main() {
await getAccessToken();
const calendarId = await getOrCreateCalendar();

const sourceEvents = await fetchICloudEvents();
const existingEvents = await fetchFeishuEvents(calendarId);

console.log('sourceEvents:', sourceEvents);
console.log('existingEvents:', existingEvents);

const toCreate = [];
const toDelete = [];

// 1. 找出需要创建的
for (const src of sourceEvents) {
const exists = existingEvents.find(
e => e.startDate === src.startDate &&
// e.endDate === src.endDate &&
e.summary === src.summary
);
if (!exists) toCreate.push(src);
}

// 2. 找出需要删除的
for (const exist of existingEvents) {
const inSource = sourceEvents.find(
s => s.startDate === exist.startDate &&
// s.endDate === exist.endDate &&
s.summary === exist.summary
);
if (exist.status === 'confirmed' && !inSource) toDelete.push(exist);
}

console.log('[toCreate]', toCreate);
console.log('[toDelete]', toDelete);

console.log(`📊 变更计划: 新增/修正 ${toCreate.length}, 删除/清理 ${toDelete.length}`);

// 执行删除
if (toDelete.length > 0) {
const headers = { Authorization: `Bearer ${TENANT_ACCESS_TOKEN}` };
for (const evt of toDelete) {
try {
await axios.delete(`${FEISHU_HOST}/calendar/v4/calendars/${calendarId}/events/${evt.event_id}`, { headers });
console.log(` - 已删除旧日程: [${evt.startDate} - ${evt.endDate}] ${evt.summary}`);
} catch (e) { console.error(` ! 删除失败: ${e.message}`); }
}
}

// 执行创建
if (toCreate.length > 0) {
const headers = { Authorization: `Bearer ${TENANT_ACCESS_TOKEN}` };
for (const evt of toCreate) {
try {
await axios.post(`${FEISHU_HOST}/calendar/v4/calendars/${calendarId}/events`, {
summary: evt.summary,
start_time: { date: evt.startDate },
end_time: { date: evt.endDate },
visibility: "public",
is_all_day_event: true
}, { headers });
console.log(` + 已创建: [${evt.startDate}${evt.startDate === evt.endDate ? '' : ' -> ' + evt.endDate}] ${evt.summary}`);
} catch (e) {
console.error(`. ! 创建失败 [${evt.startDate}]:`, e.response?.data?.msg || e.message);
}
}
}

console.log('🎉 同步完成');
}

main().catch(console.error);

总结

感谢你看到这里!这是一个 AI Vibe Coding 的试验场景,我的确感受到了 AI Coding 的未来已到:

  1. 目前 人工写需求 + AI 写代码 + 人工调试 的工作流已经初步可用,很多简单场景都可以借鉴这套模式,带来的收益是效率的绝对优势,虽然暂时不知道会不会有潜在的 bug;
  2. 在一些复杂的边界问题上,AI 可能会出错,我们需要一个规范性的说明,或者引入类似 AI friendly 架构来解决;
  3. 让 AI 自驱完成从需求、编码到测试的闭环,是未来非常值得期待的。

用 Gemini 3 实现了飞书日历节假日同步

https://mmdjiji.com/2025/2.html

作者

吉吉

发布于

2025-12-26

更新于

2025-12-26

许可协议