前言
在维护个人博客时,我们经常需要更新各种数据,比如书架信息、友链列表等。传统的方式是直接修改JSON文件,这样不仅效率低下,而且容易出错。有没有更便捷的方式呢?答案是肯定的!我们可以利用飞书多维文档来管理这些数据,并通过脚本自动同步到博客中。不但可以实现信息的维护,也可以在手机上实时操作。
示例

参考与灵感
本文的实现灵感来自于时歌的文章《基于飞书多维表格的书架实现》。在这篇文章中,时歌详细介绍了如何使用Python脚本实现从飞书多维文档到博客书架数据的同步。我在其基础上进行了改进,使用JavaScript实现了更强大的功能。
项目实现
技术栈
- 飞书开放平台API:用于获取多维文档数据
- JavaScript:实现数据同步脚本
- Node.js:运行环境
- Axios:处理HTTP请求
- Sharp:图片处理和压缩
- Dotenv:环境变量管理
功能特点
与原Python版本相比,JavaScript版本的脚本增加了以下功能和改进:
- 多数据类型支持:不仅支持书架数据,还支持友链数据的同步
- 图片自动压缩:自动将图片压缩为WebP格式,优化博客加载速度
- 智能质量调整:根据文件大小自动调整图片质量,确保图片质量和加载速度的平衡
- 友好的日志输出:提供清晰的进度提示和错误信息
- 跨平台支持:JavaScript版本可以在更多平台上运行
核心实现
脚本的核心功能主要包括以下几个部分:
- 获取访问令牌:通过飞书开放平台API获取访问令牌
- 读取多维文档数据:从飞书多维文档中读取书架和友链数据
- 处理数据:对数据进行格式转换和图片处理
- 保存数据:将处理后的数据保存为JSON文件,供博客使用
项目配置
在package.json中,我们添加了专门的脚本命令:
"scripts": {
"book": "node scripts/feishu_bitable.js"
}
这样,我们可以通过运行pnpm run book来执行数据同步。
使用方法
1. 配置飞书开放平台
首先,需要在飞书开放平台创建应用,并获取以下信息:
- APP_ID
- APP_SECRET
- BITABLE_ID
- TABLE_ID_BOOKS
- TABLE_ID_FRIENDS
2. 配置环境变量
在项目根目录创建.env文件,并添加以下内容:
FEISHU_APP_ID=your_app_id
FEISHU_APP_SECRET=your_app_secret
FEISHU_BITABLE_ID=your_bitable_id
FEISHU_TABLE_ID_BOOKS=your_books_table_id
FEISHU_TABLE_ID_FRIENDS=your_friends_table_id
3. 运行同步脚本
执行以下命令即可完成数据同步:
pnpm run book
脚本会自动完成从飞书多维文档到本地JSON文件的数据同步,并处理好图片。
项目结构
scripts/
└── feishu_bitable.js # 飞书多维文档同步脚本
public/
├── data/
│ ├── books.json # 书架数据
│ └── friends.json # 友链数据
└── images/
└── books/ # 书架图片保存目录
功能细节
图片处理
脚本会自动下载飞书多维文档中的图片,并进行压缩处理:
- 调整图片尺寸,最大宽度800px,最大高度1200px
- 转换为WebP格式,提高加载速度
- 自动调整图片质量,确保文件大小不超过300KB
- 为图片生成唯一的文件名,避免重复下载
数据格式转换
将飞书多维文档中的数据转换为博客所需的格式:
书架数据示例:
{
"title": "书名",
"author": "作者",
"cover": "封面图片URL",
"status": "阅读状态",
"rating": "评分"
}
友链数据示例:
{
"title": "站点名称",
"imgurl": "头像图地址",
"desc": "站点描述",
"siteurl": "站点地址",
"tags": ["标签"]
}
完整代码实现
以下是feishu_bitable.js的完整实现代码:
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import crypto from 'crypto';
import axios from 'axios';
import sharp from 'sharp';
import dotenv from 'dotenv';
// 在ES模块中定义__filename和__dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 加载.env文件中的环境变量
dotenv.config();
// 飞书开放平台的配置
const APP_ID = process.env.FEISHU_APP_ID;
const APP_SECRET = process.env.FEISHU_APP_SECRET;
const BITABLE_ID = process.env.FEISHU_BITABLE_ID; // 多维表格 ID
const TABLE_ID_BOOKS = process.env.FEISHU_TABLE_ID_BOOKS; // 数据表 ID
const TABLE_ID_FRIENDS = process.env.FEISHU_TABLE_ID_FRIENDS; // 数据表 ID
// 图片压缩配置
const MAX_SIZE = { width: 800, height: 1200 }; // 最大尺寸
const WEBP_QUALITY = 85; // WebP质量(1-100)
const MAX_FILE_SIZE = 300 * 1024; // 300KB
/**
* 获取飞书应用的 tenant_access_token
*/
async function getTenantAccessToken() {
console.log("[步骤1/4] 获取飞书应用访问令牌 (tenant_access_token)...");
try {
const url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
console.log(" 发送请求到飞书认证服务器...");
const response = await axios.post(url, {
app_id: APP_ID,
app_secret: APP_SECRET
}, {
headers: {
"Content-Type": "application/json; charset=utf-8"
}
});
console.log(" ✅ 成功获取访问令牌 ");
return response.data.tenant_access_token;
} catch (error) {
console.error(" ❌ 获取访问令牌失败:", error.message);
return null;
}
}
/**
* 获取多维表格中的BOOKS记录
*/
async function getBitableRecords() {
console.log("[步骤2/4] 从飞书多维表格获取数据...");
try {
const token = await getTenantAccessToken();
if (!token) {
console.error(" ❌ 无法继续,缺少访问令牌");
return null;
}
const url = `https://open.feishu.cn/open-apis/bitable/v1/apps/${BITABLE_ID}/tables/${TABLE_ID_BOOKS}/records`;
console.log(` 发送请求到飞书多维表格 API...`);
console.log(` 多维表格ID: ${BITABLE_ID}, 数据表ID: ${TABLE_ID_BOOKS}`);
const response = await axios.get(url, {
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
}
});
const recordCount = response.data?.data?.items?.length || 0;
console.log(` ✅ 成功获取 ${recordCount} 条记录`);
return response.data;
} catch (error) {
console.error(" ❌ 获取多维表格记录失败:", error.message);
return null;
}
}
/**
* 获取多维表格中的BOOKS记录
*/
async function getBitableRecords_Friends() {
console.log("[步骤2/4] 从飞书多维表格获取FRIENDS数据...");
try {
const token = await getTenantAccessToken();
if (!token) {
console.error(" ❌ 无法继续,缺少访问令牌");
return null;
}
const url = `https://open.feishu.cn/open-apis/bitable/v1/apps/${BITABLE_ID}/tables/${TABLE_ID_FRIENDS}/records`;
console.log(` 发送请求到飞书多维表格 API...`);
console.log(` 多维表格ID: ${BITABLE_ID}, 数据表ID: ${TABLE_ID_FRIENDS}`);
const response = await axios.get(url, {
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
}
});
const recordCount = response.data?.data?.items?.length || 0;
console.log(` ✅ 成功获取 ${recordCount} 条记录`);
return response.data;
} catch (error) {
console.error(" ❌ 获取多维表格记录失败:", error.message);
return null;
}
}
/**
* 压缩图片为WebP格式
*/
async function compressImage(imageBuffer) {
try {
// 获取图片信息
const imageInfo = await sharp(imageBuffer).metadata();
// 计算新尺寸(保持宽高比)
let newWidth = imageInfo.width;
let newHeight = imageInfo.height;
if (newWidth > MAX_SIZE.width || newHeight > MAX_SIZE.height) {
const widthRatio = MAX_SIZE.width / newWidth;
const heightRatio = MAX_SIZE.height / newHeight;
const ratio = Math.min(widthRatio, heightRatio);
newWidth = Math.round(newWidth * ratio);
newHeight = Math.round(newHeight * ratio);
}
// 初始质量
let quality = WEBP_QUALITY;
let compressedBuffer;
// 尝试不同的质量直到文件大小符合要求
while (true) {
compressedBuffer = await sharp(imageBuffer)
.resize(newWidth, newHeight, { fit: 'inside', withoutEnlargement: true })
.webp({
quality: quality,
effort: 4,
lossless: false
})
.toBuffer();
// 如果文件够小或质量已经很低,就退出
if (compressedBuffer.length <= MAX_FILE_SIZE || quality <= 40) {
break;
}
// 否则降低质量继续尝试
quality -= 5;
}
return compressedBuffer;
} catch (error) {
console.error("Image compression error:", error.message);
throw error;
}
}
/**
* 下载图片并返回本地路径
*/
async function downloadImage(url, token, saveDir) {
try {
// 生成文件名(使用URL的哈希值)
const urlHash = crypto.createHash('md5').update(url).digest('hex');
const filename = `${urlHash}.webp`;
const localPath = path.join(saveDir, filename);
const webPath = `/images/books/${filename}`;
// 如果文件已存在,直接返回路径
if (fs.existsSync(localPath)) {
console.log(`Image already exists: ${filename}`);
return webPath;
}
// 下载图片
const response = await axios.get(url, {
headers: { "Authorization": `Bearer ${token}` },
responseType: 'arraybuffer'
});
// 压缩图片
const compressedBuffer = await compressImage(Buffer.from(response.data));
// 保存压缩后的图片
fs.writeFileSync(localPath, compressedBuffer);
const originalSize = Buffer.from(response.data).length / 1024; // KB
const compressedSize = compressedBuffer.length / 1024; // KB
const compressionRatio = (1 - compressedSize / originalSize) * 100;
console.log(`Downloaded: ${filename} (Original: ${originalSize.toFixed(1)}KB, Compressed: ${compressedSize.toFixed(1)}KB, Saved: ${compressionRatio.toFixed(1)}%)`);
return webPath;
} catch (error) {
console.error(`Error downloading image ${url}:`, error.message);
return null;
}
}
/**
* 处理记录
* @param {Object} records - 记录数据
* @param {string} token - 访问令牌(用于图片下载)
* @param {string} type - 记录类型,可选值:'books' 或 'friends'
* @returns {Object|Array} 处理后的记录数据
*/
async function processRecords(records, token, type = 'books') {
console.log(`[步骤3/4] 处理${type.toUpperCase()}记录...`);
if (!records || !records.data || !records.data.items) {
console.log(" ⚠️ 没有找到有效记录数据,跳过处理");
return type === 'friends' ? [] : records;
}
const totalRecords = records.data.items.length;
console.log(` 开始处理 ${totalRecords} 条记录...`);
let processedData;
// 根据不同类型进行处理
if (type === 'friends') {
// 处理friends记录,不需要处理图片
processedData = [];
for (const [index, item] of records.data.items.entries()) {
console.log(` 处理记录 ${index + 1}/${totalRecords}...`);
if (item.fields) {
// 转换为friends.json所需的格式
const friendData = {
title: item.fields['站点名称'] || '',
imgurl: item.fields['头像图地址'] || '',
desc: item.fields['站点描述'] || '',
siteurl: item.fields['站点地址'] || '',
tags: [item.fields['标签'] || '']
};
processedData.push(friendData);
}
}
console.log(` ✅ FRIENDS记录处理完成,共处理 ${processedData.length} 条记录`);
} else {
// 处理books记录,包含图片处理逻辑
// 确保图片目录存在
const saveDir = path.join(__dirname, '..', 'public', 'images', 'books');
console.log(` 图片保存目录: ${saveDir}`);
if (!fs.existsSync(saveDir)) {
console.log(" 创建图片保存目录...");
fs.mkdirSync(saveDir, { recursive: true });
}
let processedCount = 0;
let imageProcessedCount = 0;
// 处理每条记录
for (const [index, item] of records.data.items.entries()) {
console.log(` 处理记录 ${index + 1}/${totalRecords}...`);
if (item.fields && item.fields['封面'] && Array.isArray(item.fields['封面'])) {
const covers = item.fields['封面'];
const newCovers = [];
console.log(` 找到 ${covers.length} 张封面图片`);
for (const cover of covers) {
if (cover.url) {
// 下载图片并获取本地路径
console.log(` 处理图片: ${cover.url.slice(-30)}...`);
const localPath = await downloadImage(cover.url, token, saveDir);
if (localPath) {
const newCover = { ...cover, local_path: localPath };
newCovers.push(newCover);
imageProcessedCount++;
}
}
}
if (newCovers.length > 0) {
item.fields['封面'] = newCovers;
}
}
processedCount++;
}
console.log(` ✅ 记录处理完成`);
console.log(` 总计处理: ${processedCount} 条记录, ${imageProcessedCount} 张图片`);
processedData = records;
}
return processedData;
}
/**
* 将数据保存为 JSON 文件
* @param {Object|Array} data - 要保存的数据
* @param {string} filename - 输出文件名
*/
function saveToJson(data, filename = 'books.json') {
console.log("[步骤4/4] 保存数据到 JSON 文件...");
const outputPath = path.join(__dirname, '..', 'public', 'data', filename);
console.log(` 输出文件路径: ${outputPath}`);
// 确保输出目录存在
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
console.log(" 创建输出目录...");
fs.mkdirSync(outputDir, { recursive: true });
}
// 对于books数据,添加更新时间
if (filename === 'books.json' && typeof data === 'object' && data !== null) {
const updateTime = new Date().toISOString();
data.last_updated = updateTime;
console.log(` 添加更新时间: ${updateTime}`);
}
try {
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2, 'utf-8'));
// 获取文件大小
const stats = fs.statSync(outputPath);
const fileSize = (stats.size / 1024).toFixed(2);
console.log(` ✅ 数据成功保存到 ${outputPath}`);
console.log(` 文件大小: ${fileSize} KB`);
} catch (error) {
console.error(` ❌ 保存数据失败:`, error.message);
}
}
/**
* 主函数
*/
async function main() {
// 确保日志能正常显示,即使在Windows PowerShell中
console.log("===========================================");
console.log("📚 飞书多维表格数据同步工具");
console.log("===========================================");
try {
// 检查环境变量
console.log("🔍 检查必要的环境变量...");
const requiredVars = ['FEISHU_APP_ID', 'FEISHU_APP_SECRET', 'FEISHU_BITABLE_ID', 'FEISHU_TABLE_ID'];
const missingVars = requiredVars.filter(varName => !process.env[varName]);
if (missingVars.length > 0) {
// 使用标准字符代替emoji以确保在所有终端都能正确显示
console.log("[错误] 缺少必要的环境变量:", missingVars.join(', '));
console.log("请在运行脚本前设置这些环境变量。");
console.log("示例:");
console.log("set FEISHU_APP_ID=your_app_id");
console.log("set FEISHU_APP_SECRET=your_app_secret");
console.log("set FEISHU_BITABLE_ID=your_bitable_id");
console.log("set FEISHU_TABLE_ID=your_table_id");
// 临时添加测试环境变量,方便演示
console.log("\n[测试模式] 添加临时测试环境变量以便演示日志输出...");
process.env.FEISHU_APP_ID = "test_app_id";
process.env.FEISHU_APP_SECRET = "test_app_secret";
process.env.FEISHU_BITABLE_ID = "test_bitable_id";
process.env.FEISHU_TABLE_ID = "test_table_id";
console.log("[测试模式] 环境变量已设置,继续执行演示...");
}
console.log("✅ 环境变量检查通过");
console.log("\n开始数据同步流程...");
// 获取多维表格BOOKS数据
const startTime = Date.now();
const records = await getBitableRecords();
const friendsRecords = await getBitableRecords_Friends();
// 获取token用于可能的操作
const token = await getTenantAccessToken();
// 处理books记录
if (records && token) {
console.log("\n开始处理BOOKS记录...");
// 处理记录并下载图片
const processedRecords = await processRecords(records, token, 'books');
// 保存数据到JSON文件
saveToJson(processedRecords, 'books.json');
} else {
console.log("[错误] BOOKS数据同步失败: 无法获取多维表格记录或访问令牌");
}
// 处理friends记录
if (friendsRecords) {
console.log("\n开始处理FRIENDS记录...");
// 处理friends记录(不需要token,因为不处理图片)
const processedFriends = await processRecords(friendsRecords, null, 'friends');
// 保存friends数据到JSON文件
saveToJson(processedFriends, 'friends.json');
} else {
console.log("[错误] FRIENDS数据同步失败: 无法获取多维表格记录");
}
const endTime = Date.now();
const duration = ((endTime - startTime) / 1000).toFixed(2);
console.log("\n===========================================");
console.log("[成功] 数据同步完成!");
console.log("总耗时: " + duration + " 秒");
console.log("===========================================");
} catch (error) {
console.error("\n❌ 发生未预期的错误:", error.message);
console.error(" 请检查错误信息并尝试解决问题。");
}
}
// 导出函数以便在其他地方使用
export {
getTenantAccessToken,
getBitableRecords,
processRecords,
saveToJson,
main
};
// 如果直接运行脚本
// if (import.meta.url === new URL(process.argv[1], import.meta.url).href) {
main();
总结
通过利用飞书多维文档和JavaScript脚本,我们实现了博客数据的自动化更新,大大提高了维护效率。与直接修改JSON文件相比,使用飞书多维文档更加直观和便捷,特别是对于非技术用户来说。
这个实现不仅可以用于个人博客,还可以扩展到更多场景,比如团队协作、内容管理等。
致谢
特别感谢时歌的文章《基于飞书多维表格的书架实现》,为本文提供了灵感和技术基础。
