3183 字
16 分钟
利用飞书多维文档实现博客的信息更新

前言#

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

示例#

示例图片 示例图片 示例图片

书架

友链

参考与灵感#

本文的实现灵感来自于时歌的文章《基于飞书多维表格的书架实现》。在这篇文章中,时歌详细介绍了如何使用Python脚本实现从飞书多维文档到博客书架数据的同步。我在其基础上进行了改进,使用JavaScript实现了更强大的功能。

项目实现#

技术栈#

  • 飞书开放平台API:用于获取多维文档数据
  • JavaScript:实现数据同步脚本
  • Node.js:运行环境
  • Axios:处理HTTP请求
  • Sharp:图片处理和压缩
  • Dotenv:环境变量管理

功能特点#

与原Python版本相比,JavaScript版本的脚本增加了以下功能和改进:

  1. 多数据类型支持:不仅支持书架数据,还支持友链数据的同步
  2. 图片自动压缩:自动将图片压缩为WebP格式,优化博客加载速度
  3. 智能质量调整:根据文件大小自动调整图片质量,确保图片质量和加载速度的平衡
  4. 友好的日志输出:提供清晰的进度提示和错误信息
  5. 跨平台支持:JavaScript版本可以在更多平台上运行

核心实现#

脚本的核心功能主要包括以下几个部分:

  1. 获取访问令牌:通过飞书开放平台API获取访问令牌
  2. 读取多维文档数据:从飞书多维文档中读取书架和友链数据
  3. 处理数据:对数据进行格式转换和图片处理
  4. 保存数据:将处理后的数据保存为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文件相比,使用飞书多维文档更加直观和便捷,特别是对于非技术用户来说。

这个实现不仅可以用于个人博客,还可以扩展到更多场景,比如团队协作、内容管理等。

致谢#

特别感谢时歌的文章《基于飞书多维表格的书架实现》,为本文提供了灵感和技术基础。

参考链接#

利用飞书多维文档实现博客的信息更新
https://blog.ai-nous.com/posts/利用飞书多维文档实现博客的信息更新/
作者
PankitGG
发布于
2025-12-03
许可协议
CC BY-NC-SA 4.0