利用飞书多维文档实现博客的信息更新
前言
在维护个人博客时,我们经常需要更新各种数据,比如书架信息、友链列表等。传统的方式是直接修改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_idFEISHU_APP_SECRET=your_app_secretFEISHU_BITABLE_ID=your_bitable_idFEISHU_TABLE_ID_BOOKS=your_books_table_idFEISHU_TABLE_ID_FRIENDS=your_friends_table_id3. 运行同步脚本
执行以下命令即可完成数据同步:
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和__dirnameconst __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; // 多维表格 IDconst TABLE_ID_BOOKS = process.env.FEISHU_TABLE_ID_BOOKS; // 数据表 IDconst 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文件相比,使用飞书多维文档更加直观和便捷,特别是对于非技术用户来说。
这个实现不仅可以用于个人博客,还可以扩展到更多场景,比如团队协作、内容管理等。
致谢
特别感谢时歌的文章《基于飞书多维表格的书架实现》,为本文提供了灵感和技术基础。
参考链接
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!