尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

飞书代码片段自动化同步至Obsidian知识库:构建个人代码资产管理系统

飞书代码片段自动化同步至Obsidian知识库:构建个人代码资产管理系统 1. 背景与核心概念在日常工作中你是否遇到过这样的困境飞书文档里沉淀了大量实用的组件脚本比如自动处理表格数据的Python脚本、快速生成周报的Shell脚本或是用于数据清洗的JavaScript代码片段。这些脚本分散在各个文档中查找和使用起来非常不便。与此同时你又在使用 Obsidian 构建个人知识库希望将所有知识资产包括这些代码都集中管理、关联和复用。“飞书里的组件脚本全塞进 Obsidian 只要一键”这听起来像是一个美好的愿景。本文将为你揭示如何通过一套自动化流程近乎“一键”地将飞书文档中的代码组件高效、结构化地迁移到你的 Obsidian 知识库中。这不仅仅是简单的复制粘贴而是构建一个可持续、可扩展的代码知识管理体系。核心概念解析飞书组件脚本这里指的是存储在飞书云文档包括文档、多维表格、知识库中的代码片段。它们可能是技术方案里的示例代码、自动化流程中的关键函数或是团队共享的工具脚本。其特点是“嵌入在文档中”而非独立的代码文件。Obsidian一款以“双向链接”和“本地优先”为核心的知识管理软件。它使用纯文本 Markdown 文件存储笔记并围绕这些文件构建强大的关联网络。对于开发者而言它不仅是笔记工具更是管理代码片段、技术文档、项目日志的绝佳平台。“一键”集成并非字面意义上的一个按钮而是指通过脚本化、自动化的方式将原本需要手动进行的“查找-复制-创建文件-粘贴-格式化”等多步操作简化为一个命令或一个触发动作。其核心是“自动化流水线”。为什么需要这样做知识集中化避免知识散落在多个协作平台飞书、GitHub、本地文档形成统一的知识源。增强可检索性Obsidian 强大的全文搜索和标签系统让你能瞬间找到半年前写过的某个数据处理函数。建立知识关联在 Obsidian 中你可以轻松地将一个脚本与相关的设计文档、API说明、调试日志笔记链接起来形成立体化的知识网络。代码复用与版本化将脚本作为独立的.md或.py/.js文件管理便于版本控制如结合 Git和跨项目复用。离线可用Obsidian 的本地存储特性确保你的核心代码资产不依赖于网络随时可用。本文将围绕“飞书OpenAPI 本地处理脚本 Obsidian 模板”的技术栈手把手教你搭建这条自动化流水线。2. 环境准备与版本说明在开始构建自动化流程前请确保你的环境满足以下要求。版本号以当前常见稳定版为例实际操作时请根据官方文档进行微调。2.1 基础运行环境操作系统Windows 10/11, macOS 10.15, 或主流 Linux 发行版如 Ubuntu 20.04。本文示例命令以 macOS/Linux 的 Bash 和 Windows 的 PowerShell 为主。PythonPython 3.8 或更高版本。这是我们的核心脚本语言。检查命令python --version或python3 --versionNode.js可选但推荐安装14.0部分社区工具可能依赖它。检查命令node --version2.2 飞书开放平台准备你需要一个飞书开发者账号并创建一个自建应用来获取 API 访问权限。访问 飞书开放平台 并登录。进入“开发者后台”点击“创建企业自建应用”。填写应用名称如Obsidian Script Sync描述。获取关键凭证App ID与App Secret在“凭证与基础信息”页面获取。这是调用 API 的钥匙。权限配置为应用添加以下权限contact:contact:readonly_as_app(读取部门与用户信息用于权限判断)drive:drive:readonly(云空间只读权限用于读取文档)具体所需权限取决于你要访问的文档范围全员/指定部门/个人。请遵循最小权限原则。2.3 Obsidian 准备Obsidian安装最新版 Obsidian 并创建一个用于存放代码的知识库Vault。核心插件确保“模板”核心插件已启用。我们稍后会用到。社区插件推荐Templater比原生模板更强大支持执行 JavaScript 代码。本教程的高级部分会涉及。Dataview用于高级查询和展示脚本集合非必需但能极大提升管理体验。2.4 项目目录结构本地我们将创建一个本地项目文件夹来存放所有脚本和配置文件。feishu-to-obsidian/ ├── config.yaml # 配置文件存放飞书凭证、目标目录等 ├── requirements.txt # Python 依赖列表 ├── main.py # 主脚本 ├── utils/ # 工具模块目录 │ ├── __init__.py │ ├── feishu_client.py # 飞书 API 客户端 │ └── obsidian_helper.py # Obsidian 文件操作助手 └── output/ # 脚本同步的临时输出目录可选3. 核心原理与流程拆解整个“一键”同步流程可以分解为三个核心阶段理解它们有助于你后续的定制和排错。3.1 阶段一从飞书获取数据拉取目标通过飞书 OpenAPI找到包含目标脚本的文档并提取出其中的代码内容。身份认证使用App ID和App Secret获取tenant_access_token后续所有 API 调用都需携带此 Token。文档定位方案A精准如果你知道文档的folder_token知识库或file_token文档直接访问。方案B搜索更通用。使用搜索云文档API通过关键词如“脚本”、“function”、“python”在全公司或指定范围内搜索文档。内容提取调用获取文档纯文本内容或获取文档块内容API。难点API 返回的是 JSON 结构化的文档块数据需要从中识别出代码块。代码块通常对应的block_type为code其elements中包含具体的代码文本。关键APIPOST /auth/v3/tenant_access_token/internalGET /drive/v1/files/:file_token/raw_contentPOST /drive/v1/files/:file_token/search。3.2 阶段二内容处理与转换转换目标将提取出的原始 JSON 数据清洗、转换为适合 Obsidian 的格式。代码块识别遍历文档块筛选出type为code的块。元数据提取从代码块中提取语言类型如python,javascript,bash。尝试从代码块的前后文本或特定格式的注释中提取脚本描述、作者、创建日期等信息。格式化将代码包装成 Obsidian 代码块语法language\n{code}\n。生成标准的 YAML Front Matter元数据区用于 Dataview 插件查询。例如--- source: “飞书-文档名” script_type: “python” description: “用于清洗用户数据的Pandas脚本” author: “张三” created: 2023-10-27 tags: [data-processing, pandas] ---文件命名根据脚本功能或所在文档标题生成有意义的文件名如user_data_cleaner.py.md。3.3 阶段三写入 Obsidian 知识库写入目标将处理好的内容以文件形式写入指定的 Obsidian 知识库目录。路径确定根据配置确定脚本在 Obsidian 库中的存放目录如Scripts/DataProcessing/。文件写入使用 Python 的open()函数或shutil模块创建并写入文件。避免冲突检查目标文件是否已存在可采用覆盖、跳过或创建新版本如添加时间戳的策略。触发 Obsidian 更新由于 Obsidian 监听文件系统变化新文件写入后会自动出现在文件列表中。流程总结获取Token - 搜索/定位文档 - 获取文档内容 - 解析并过滤代码块 - 提取元数据并格式化 - 写入Obsidian本地文件。4. 完整实战案例构建同步脚本现在我们一步步实现这个同步工具。我们将创建一个命令行工具运行python main.py即可同步。4.1 创建项目与依赖首先创建项目目录并初始化 Python 环境。mkdir feishu-to-obsidian cd feishu-to-obsidian python -m venv venv # 创建虚拟环境 # Windows (PowerShell): venv\Scripts\activate # macOS/Linux: source venv/bin/activate创建requirements.txt文件requests2.28.0 pyyaml6.0 python-dotenv0.21.0 # 用于管理环境变量安装依赖pip install -r requirements.txt4.2 编写配置文件创建config.yaml存放你的个性化配置。注意切勿将此文件提交至公开版本库。# config.yaml feishu: app_id: “cli_xxxxxx” # 替换为你的 App ID app_secret: “xxxxxx” # 替换为你的 App Secret # 搜索关键词用于定位包含脚本的文档 search_keywords: - “python” - “javascript” - “脚本” - “function” # 可选指定搜索范围知识库的 folder_token search_folder_token: “fldcnxxxxxx” obsidian: # 你的 Obsidian 知识库绝对路径 vault_path: “/Users/YourName/Documents/Obsidian Vaults/MyKnowledgeBase” # 脚本存放的子目录 target_dir: “90_Assets/CodeScripts” # 文件命名模板{doc_title} 文档标题 {script_name} 脚本名 {timestamp} 时间戳 file_name_template: “{doc_title}_{script_name}_{timestamp}” # 是否在文件顶部添加 YAML Front Matter enable_frontmatter: true sync: # 同步模式all (同步所有找到的), incremental (仅同步新的或修改过的) mode: “incremental” # 临时记录上次同步状态的文件 state_file: “.sync_state.json”4.3 实现飞书 API 客户端创建utils/feishu_client.py封装飞书 API 调用。# utils/feishu_client.py import requests import time import logging from typing import Optional, Dict, Any, List logging.basicConfig(levellogging.INFO) logger logging.getLogger(__name__) class FeishuClient: def __init__(self, app_id: str, app_secret: str): self.app_id app_id self.app_secret app_secret self.tenant_access_token None self.token_expire_time 0 self.base_url “https://open.feishu.cn/open-apis” def _get_tenant_access_token(self) - str: 获取并缓存 tenant_access_token if self.tenant_access_token and time.time() self.token_expire_time - 60: return self.tenant_access_token url f“{self.base_url}/auth/v3/tenant_access_token/internal” data { “app_id”: self.app_id, “app_secret”: self.app_secret } resp requests.post(url, jsondata) resp.raise_for_status() result resp.json() if result.get(“code”) ! 0: raise Exception(f“Failed to get token: {result}”) self.tenant_access_token result[“tenant_access_token”] # 令牌有效期通常为2小时这里保守设置为7100秒 self.token_expire_time time.time() 7100 logger.info(“Tenant access token refreshed.”) return self.tenant_access_token property def headers(self) - Dict[str, str]: token self._get_tenant_access_token() return { “Authorization”: f“Bearer {token}”, “Content-Type”: “application/json; charsetutf-8” } def search_files(self, query: str, folder_token: Optional[str] None) - List[Dict]: 搜索云文档 url f“{self.base_url}/drive/v1/files/search” payload { “query”: query, “search_scopes”: [{ “type”: “wiki”, # 搜索知识库 “id”: folder_token }] if folder_token else [{type: “all_public”}] # 根据权限调整 } resp requests.post(url, jsonpayload, headersself.headers) resp.raise_for_status() result resp.json() if result.get(“code”) ! 0: logger.error(f“Search failed: {result}”) return [] return result.get(“data”, {}).get(“files”, []) def get_file_blocks(self, file_token: str) - List[Dict]: 获取文档的块内容结构化数据 url f“{self.base_url}/docx/v1/documents/{file_token}/blocks” params {“page_size”: 200} all_blocks [] page_token None while True: if page_token: params[“page_token”] page_token resp requests.get(url, paramsparams, headersself.headers) resp.raise_for_status() result resp.json() if result.get(“code”) ! 0: logger.error(f“Get blocks failed for {file_token}: {result}”) break data result.get(“data”, {}) all_blocks.extend(data.get(“items”, [])) page_token data.get(“page_token”) if not page_token: break return all_blocks def extract_code_from_blocks(self, blocks: List[Dict]) - List[Dict]: 从文档块中提取代码块 code_snippets [] for block in blocks: block_type block.get(“block_type”) block_id block.get(“block_id”) # 类型为 3 通常代表代码块需根据API文档确认此处为示例 if block_type 3: text_elements block.get(“text”, {}).get(“elements”, []) code_text “” language “text” for elem in text_elements: # 实际结构更复杂需要根据API响应具体解析 # 这里是一个简化示例 if elem.get(“type”) “textRun”: code_text elem.get(“text_run”, {}).get(“content”, “”) # 可能通过样式或属性判断语言 if “language” in elem.get(“style”, {}): language elem[“style”][“language”] if code_text.strip(): code_snippets.append({ “block_id”: block_id, “language”: language, “code”: code_text, “source_block”: block # 保留原始信息 }) logger.info(f“Extracted {len(code_snippets)} code snippets.”) return code_snippets4.4 实现 Obsidian 文件助手创建utils/obsidian_helper.py负责文件创建和格式化。# utils/obsidian_helper.py import os import yaml from datetime import datetime from pathlib import Path import logging import json logger logging.getLogger(__name__) class ObsidianHelper: def __init__(self, vault_path: str, target_dir: str): self.vault_root Path(vault_path).expanduser().resolve() self.target_dir Path(target_dir) self.scripts_dir self.vault_root / self.target_dir self.scripts_dir.mkdir(parentsTrue, exist_okTrue) logger.info(f“Obsidian scripts directory: {self.scripts_dir}”) def generate_frontmatter(self, metadata: Dict) - str: 生成 YAML Front Matter frontmatter {“---”} # 确保必要的字段 default_meta { “aliases”: [], “tags”: [“feishu-sync”, “script”], “created”: datetime.now().strftime(“%Y-%m-%d %H:%M”), “updated”: datetime.now().strftime(“%Y-%m-%d %H:%M”), } default_meta.update(metadata) # 使用 yaml.dump 确保格式正确 yaml_content yaml.dump(default_meta, allow_unicodeTrue, sort_keysFalse).strip() return f“---\n{yaml_content}\n---\n\n” def create_script_note(self, script_data: Dict, doc_title: str, sync_timestamp: str): 在 Obsidian 中创建脚本笔记文件 script_name script_data.get(“name”, “unnamed_script”) language script_data.get(“language”, “text”) code_content script_data.get(“code”, “”) # 1. 生成文件名 safe_doc_title “”.join(c if c.isalnum() else “_” for c in doc_title[:50]) safe_script_name “”.join(c if c.isalnum() else “_” for c in script_name[:30]) file_name f“{safe_doc_title}_{safe_script_name}_{sync_timestamp}.md” file_path self.scripts_dir / file_name # 2. 构建文件内容 content_lines [] # 添加 Front Matter frontmatter_meta { “source”: f“飞书文档: {doc_title}”, “script_type”: language, “description”: script_data.get(“description”, “”), “original_block_id”: script_data.get(“block_id”, “”), } content_lines.append(self.generate_frontmatter(frontmatter_meta)) # 添加代码块 content_lines.append(f“{language}\n{code_content}\n\n”) # 可选添加一些上下文说明 content_lines.append(“## 说明\n”) content_lines.append(f“- 本脚本从飞书文档 **{doc_title}** 同步而来。\n”) content_lines.append(f“- 原始代码块 ID: {script_data.get(‘block_id’, ‘N/A’)}\n”) content_lines.append(f“- 同步时间: {datetime.now().strftime(‘%Y-%m-%d %H:%M:%S’)}\n”) # 3. 写入文件 try: with open(file_path, ‘w’, encoding‘utf-8’) as f: f.writelines(content_lines) logger.info(f“Created note: {file_path}”) return str(file_path) except IOError as e: logger.error(f“Failed to write file {file_path}: {e}”) return None4.5 编写主同步逻辑创建main.py串联整个流程。# main.py import yaml import json import hashlib from datetime import datetime from pathlib import Path import logging from utils.feishu_client import FeishuClient from utils.obsidian_helper import ObsidianHelper logging.basicConfig(levellogging.INFO, format‘%(asctime)s - %(name)s - %(levelname)s - %(message)s’) logger logging.getLogger(__name__) def load_config(config_path“config.yaml”): with open(config_path, ‘r’, encoding‘utf-8’) as f: return yaml.safe_load(f) def calculate_content_hash(content: str) - str: 计算内容哈希用于判断是否更新 return hashlib.md5(content.encode(‘utf-8’)).hexdigest() def load_sync_state(state_file: str) - Dict: 加载上次同步状态 if Path(state_file).exists(): try: with open(state_file, ‘r’, encoding‘utf-8’) as f: return json.load(f) except json.JSONDecodeError: return {“processed_blocks”: {}} return {“processed_blocks”: {}} def save_sync_state(state: Dict, state_file: str): 保存同步状态 with open(state_file, ‘w’, encoding‘utf-8’) as f: json.dump(state, f, indent2) def main(): config load_config() feishu_cfg config[‘feishu’] obsidian_cfg config[‘obsidian’] sync_cfg config[‘sync’] # 初始化客户端和助手 client FeishuClient(feishu_cfg[‘app_id’], feishu_cfg[‘app_secret’]) helper ObsidianHelper(obsidian_cfg[‘vault_path’], obsidian_cfg[‘target_dir’]) # 加载状态 sync_state load_sync_state(sync_cfg[‘state_file’]) processed_blocks sync_state.get(“processed_blocks”, {}) sync_timestamp datetime.now().strftime(“%Y%m%d_%H%M%S”) new_processed_blocks processed_blocks.copy() # 遍历搜索关键词 for keyword in feishu_cfg[‘search_keywords’]: logger.info(f“Searching for documents with keyword: ‘{keyword}’”) files client.search_files(keyword, feishu_cfg.get(‘search_folder_token’)) logger.info(f“Found {len(files)} files.”) for file_info in files: file_token file_info.get(“file_token”) file_name file_info.get(“name”, “Untitled”) logger.info(f“Processing file: {file_name} ({file_token})”) try: blocks client.get_file_blocks(file_token) code_snippets client.extract_code_from_blocks(blocks) for snippet in code_snippets: block_id snippet[“block_id”] content_hash calculate_content_hash(snippet[“code”]) # 增量同步检查 if sync_cfg[‘mode’] ‘incremental’: if block_id in processed_blocks: if processed_blocks[block_id] content_hash: logger.debug(f“Skipping unchanged block: {block_id}”) continue # 创建笔记 script_data { “name”: f“snippet_{block_id[-8:]}”, “language”: snippet[“language”], “code”: snippet[“code”], “block_id”: block_id, “description”: f“Code snippet from {file_name}” } helper.create_script_note(script_data, file_name, sync_timestamp) # 记录处理状态 new_processed_blocks[block_id] content_hash except Exception as e: logger.error(f“Error processing file {file_name}: {e}”, exc_infoTrue) continue # 保存本次同步状态 sync_state[“processed_blocks”] new_processed_blocks sync_state[“last_sync”] datetime.now().isoformat() save_sync_state(sync_state, sync_cfg[‘state_file’]) logger.info(“Sync completed successfully!”) if __name__ “__main__”: main()4.6 运行与验证填充配置将你的飞书app_id,app_secret和 Obsidian 知识库路径填入config.yaml。首次运行在项目根目录下执行python main.py。观察日志控制台会输出搜索、处理和创建文件的日志。检查结果打开你的 Obsidian 知识库进入配置的target_dir如90_Assets/CodeScripts你应该能看到新创建的 Markdown 笔记文件里面包含了从飞书提取的代码并带有完整的 Front Matter。至此一个基础的“一键”同步工具就完成了。运行一次脚本就能将飞书文档中的代码块批量迁移到 Obsidian。5. 常见问题与排查思路在实际运行中你可能会遇到一些问题。下表列出了常见问题及其解决方法。问题现象可能原因排查思路与解决方案运行脚本时报错401或99991663(无权限)1. App ID/Secret 错误。2. 应用未发布或未获得所需权限。3. Token 获取失败。1. 检查config.yaml中的凭证是否正确确保无多余空格。2. 登录飞书开放平台在“应用凭证”页面核对。3. 在“权限管理”中确认已添加并发布了drive:drive:readonly等必要权限。4. 检查_get_tenant_access_token方法中的 API 响应确认code为 0。搜索不到任何文档1. 搜索关键词不匹配。2. 应用权限范围不足。3. 搜索 API 参数search_scopes设置错误。1. 尝试使用更通用的关键词如“代码”。2. 确认你要搜索的文档在应用可见范围内如企业内全员可见或指定知识库。3. 在飞书开发者后台使用API 调试台手动调用搜索云文档API验证参数和权限。成功找到文档但提取不到代码块1. 文档内容 API 返回的数据结构解析错误。2. 代码块在飞书中的block_type不是预期的值。1. 在extract_code_from_blocks函数中添加调试日志打印出block的原始结构确认代码块对应的键名。2. 飞书文档 API 版本可能更新。查阅最新的 飞书云文档 OpenAPI 文档 调整解析逻辑。Obsidian 中文件创建成功但内容乱码或格式错误文件编码问题或 Front Matter 格式错误。1. 确保所有文件操作读、写都指定了encoding‘utf-8’。2. 检查generate_frontmatter函数生成的 YAML 格式是否正确首尾---键值对缩进。可以使用在线 YAML 校验器检查。3. 在 Obsidian 中打开“设置 - 文件与链接 - 新建笔记的默认位置”确认不是这里导致的问题。脚本运行缓慢尤其是文档很多时1. 网络请求频繁。2. 未做分页处理或并发控制。1. 在search_files和get_file_blocks中合理设置page_size参数减少请求次数。2. 考虑对多个文档的处理使用异步库如aiohttp或线程池但注意飞书 API 可能有频率限制。3. 启用incremental模式只处理新内容。增量同步模式下已修改的代码块没有被更新calculate_content_hash函数可能对空白字符敏感或者代码块元数据如语言变化未触发更新。1. 在计算哈希前可以对代码字符串进行标准化处理如.strip()或统一换行符\n。2. 考虑将language也纳入哈希计算或状态判断的逻辑中。Obsidian 中无法识别 Front Matter 或 Dataview 查询不到Front Matter 格式错误或 Dataview 插件未正确启用/查询语法错误。1. 确保 Front Matter 是文件最开头的内容前后各有三个短横线---。2. 在 Obsidian 中检查“设置 - 社区插件”中 Dataview 已启用。3. 尝试一个简单的查询确认dataviewbr TABLE file.name, script_typebr FROM “90_Assets/CodeScripts”br WHERE script_typebr6. 最佳实践与工程建议将脚本集成到日常工作流中以下实践能让它更可靠、更强大。6.1 安全与凭证管理永远不要硬编码密钥config.yaml中的app_secret是最高机密。建议将其移至环境变量或专用的 secrets 管理工具。创建.env文件加入.gitignore# .env FEISHU_APP_IDcli_xxxxxx FEISHU_APP_SECRETxxxxxx在config.yaml或代码中通过os.getenv(“FEISHU_APP_SECRET”)读取。使用最小权限在飞书开放平台只为应用配置它完成工作所必需的最小权限范围如只读特定知识库。状态文件安全.sync_state.json可能包含文档和块 ID虽不敏感但也建议将其放在项目目录内并避免提交到公开仓库。6.2 增强同步脚本错误处理与重试网络请求可能失败。为关键 API 调用如get_file_blocks添加重试机制如tenacity库和更细致的异常捕获。日志记录使用logging模块将日志输出到文件便于后期排查。区分INFO,WARNING,ERROR级别。配置文件验证在脚本启动时验证config.yaml中路径是否存在、凭证是否已设置。支持更多源除了飞书文档可以考虑扩展支持从飞书“多维表格”的附件字段、GitHub Gist 或 GitLab Snippets 同步代码。6.3 优化 Obsidian 知识库管理分类与标签系统在create_script_note函数中根据代码语言或关键词自动添加更丰富的标签如#python,#data_analysis。这能极大提升在 Obsidian 内的检索效率。利用 Dataview 创建仪表盘在 Obsidian 中创建一个“脚本库总览”笔记使用 Dataview 查询并展示所有同步的脚本。## ️ 我的脚本库 dataview TABLE description AS “描述”, script_type AS “语言”, created AS “创建时间”, file.ctime AS “文件创建时间” FROM “90_Assets/CodeScripts” SORT file.ctime DESC与笔记关联不要让你的代码脚本孤立存在。在相关的项目笔记、学习笔记中使用 Obsidian 的内部链接[[脚本文件名]]引用它们构建知识网络。版本控制将整个 Obsidian 知识库或Scripts目录用 Git 管理起来。这为你的代码片段提供了版本历史可以回滚到任意时间点。6.4 实现真正的“一键”系统定时任务在 macOS/Linux 上可以使用crontab在 Windows 上可以使用“任务计划程序”定期如每天凌晨2点运行同步脚本。Linux/macOS Crontab 示例每天2点运行0 2 * * * cd /path/to/feishu-to-obsidian /usr/bin/python3 main.py /path/to/sync.log 21Obsidian 插件集成高级如果你熟悉 JavaScript可以开发一个简单的 Obsidian 插件在 Obsidian 内部提供一个按钮或命令直接触发 Python 脚本运行。这需要插件调用外部进程或一个本地 HTTP 服务。简化运行在项目根目录创建一个 shell 脚本如sync.sh或sync.bat封装激活虚拟环境和运行 Python 的命令实现双击运行。通过遵循以上实践你可以将一个简单的同步脚本升级为一个稳定、自动化、深度融入你知识工作流的强大工具。它不仅解决了代码碎片化的问题更通过 Obsidian 的链接能力让你的技术知识真正流动和增值。
返回列表