This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
openclaw-skills/skills/kb-workflow/src/outputs/obsidian_writer.py
T

386 lines
11 KiB
Python

"""
Obsidian Writer - 将内容写入 Obsidian vault
"""
import os
import re
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, Optional
from ..models import Content
class ObsidianWriter:
"""Obsidian vault 写入器"""
def __init__(self, config):
"""
初始化写入器
Args:
config: 配置对象
"""
self.config = config
self.vault_path = config.vault_path
def write(self, data: Dict[str, Any], config) -> Optional[str]:
"""
将内容写入 Obsidian vault
Args:
data: 内容数据字典或 Content 对象
config: 配置对象
Returns:
写入的文件路径,失败返回 None
"""
# 如果 data 是 Content 对象,转换为字典
if isinstance(data, Content):
content = data
else:
content = Content.from_dict(data)
# 构建文件路径
file_path = self._build_file_path(content)
# 创建目录结构
file_path.parent.mkdir(parents=True, exist_ok=True)
# 生成 Markdown 内容
markdown_content = self._generate_markdown(content)
# 写入文件
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(markdown_content)
return str(file_path)
except Exception as e:
print(f"写入文件失败: {e}")
return None
def _build_file_path(self, content: Content) -> Path:
"""
构建文件路径
格式: {vault}/videos/bilibili/{author}/{date}_{title}.md
Args:
content: Content 对象
Returns:
文件路径
"""
# 获取日期(从 created_at 提取日期部分)
date_str = self._extract_date(content.created_at)
# 清理标题(移除特殊字符)
safe_title = self._sanitize_filename(content.title)
# 构建文件名
filename = f"{date_str}_{safe_title}.md"
# 构建完整路径
# 格式: {vault}/videos/{source}/{author}/{filename}
path_parts = [
self.vault_path,
'videos',
content.source,
self._sanitize_filename(content.author),
filename
]
return Path(*path_parts)
def _extract_date(self, iso_date: str) -> str:
"""
从 ISO 日期字符串提取日期部分
Args:
iso_date: ISO 格式日期字符串
Returns:
YYYY-MM-DD 格式日期字符串
"""
try:
dt = datetime.fromisoformat(iso_date)
return dt.strftime('%Y-%m-%d')
except:
# 如果解析失败,返回当前日期
return datetime.now().strftime('%Y-%m-%d')
def _sanitize_filename(self, filename: str) -> str:
"""
清理文件名,移除不适合文件系统的字符
Args:
filename: 原始文件名
Returns:
清理后的文件名
"""
# 移除不适合文件系统的字符
forbidden_chars = '<>:"/\\|?*'
for char in forbidden_chars:
filename = filename.replace(char, '')
# 移除首尾空格
filename = filename.strip()
# 限制长度
if len(filename) > 100:
filename = filename[:100]
# 如果为空,使用默认值
if not filename:
filename = 'untitled'
return filename
def _generate_markdown(self, content: Content) -> str:
"""
生成 Obsidian Markdown 内容
格式:
---
title: xxx
source: bilibili
url: xxx
author: xxx
duration: xxx
date: xxx
tags: [xxx, xxx]
---
# {title}
...内容...
Args:
content: Content 对象
Returns:
Markdown 内容字符串
"""
# 生成 frontmatter
frontmatter = self._generate_frontmatter(content)
# 生成正文
body = self._generate_body(content)
# 组合
markdown = f"{frontmatter}\n{body}"
return markdown
def _generate_frontmatter(self, content: Content) -> str:
"""
生成 YAML frontmatter
Args:
content: Content 对象
Returns:
Frontmatter 字符串
"""
# 格式化时长
duration_str = self._format_duration(content.duration) if content.duration else "N/A"
# 格式化日期
date_str = self._extract_date(content.created_at)
# 构建标签列表
tags = self._build_tags(content)
tags_yaml = ", ".join([f'"{tag}"' for tag in tags])
frontmatter = f"""---
title: {content.title}
source: {content.source}
url: {content.url}
author: {content.author}
duration: {duration_str}
date: {date_str}
tags: [{tags_yaml}]
---"""
return frontmatter
def _build_tags(self, content: Content) -> list:
"""
构建标签列表
Args:
content: Content 对象
Returns:
标签列表
"""
tags = []
# 添加自动标签
if self.config.auto_tags:
tags.extend(self.config.default_tags)
# 添加来源标签
if content.source:
tags.append(content.source)
# 添加内容类型标签
if content.content_type:
tags.append(content.content_type.value)
# 添加 AI 生成的标签
if content.tags:
tags.extend(content.tags)
# 去重
tags = list(dict.fromkeys(tags))
return tags
def _format_duration(self, duration_seconds: int) -> str:
"""
格式化时长为 HH:MM:SS
Args:
duration_seconds: 时长(秒)
Returns:
格式化的时长字符串
"""
if duration_seconds is None:
return "N/A"
hours = duration_seconds // 3600
minutes = (duration_seconds % 3600) // 60
seconds = duration_seconds % 60
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:02d}"
else:
return f"{minutes}:{seconds:02d}"
def _generate_body(self, content: Content) -> str:
"""
生成 Markdown 正文内容
Args:
content: Content 对象
Returns:
正文内容字符串
"""
body_parts = []
# 标题
body_parts.append(f"# {content.title}")
body_parts.append("")
# 一句话摘要
if content.one_liner:
body_parts.append(f"> {content.one_liner}")
body_parts.append("")
# 摘要
if content.summary:
body_parts.append("## 摘要")
body_parts.append("")
body_parts.append(content.summary)
body_parts.append("")
# 关键要点
if content.key_points:
body_parts.append("## 关键要点")
body_parts.append("")
for point in content.key_points:
body_parts.append(f"- {point}")
body_parts.append("")
# 大纲
if content.outline:
body_parts.append("## 大纲")
body_parts.append("")
for i, item in enumerate(content.outline, 1):
body_parts.append(f"{i}. {item}")
body_parts.append("")
# 时间线
if content.timeline:
body_parts.append("## 时间线")
body_parts.append("")
for entry in content.timeline:
timestamp = entry.timestamp or "N/A"
title = entry.title or ""
description = entry.description or ""
body_parts.append(f"### {timestamp} - {title}")
if description:
body_parts.append(description)
body_parts.append("")
# 内容分析
if content.analysis and content.analysis.outline:
body_parts.append("## 详细分析")
body_parts.append("")
analysis = content.analysis
# 前置知识
if analysis.prerequisites:
body_parts.append("### 前置知识")
body_parts.append("")
for prereq in analysis.prerequisites:
body_parts.append(f"- {prereq}")
body_parts.append("")
# 详细大纲
if analysis.outline:
body_parts.append("### 详细大纲")
body_parts.append("")
for section in analysis.outline:
if isinstance(section, dict):
title = section.get('title', '')
content_text = section.get('content', '')
body_parts.append(f"#### {title}")
if content_text:
body_parts.append(content_text)
else:
body_parts.append(str(section))
body_parts.append("")
# 常见坑点
if analysis.pitfalls:
body_parts.append("### 常见坑点")
body_parts.append("")
for pitfall in analysis.pitfalls:
body_parts.append(f"- {pitfall}")
body_parts.append("")
# 实践建议
if analysis.practice_advice:
body_parts.append("### 实践建议")
body_parts.append("")
for advice in analysis.practice_advice:
body_parts.append(f"- {advice}")
body_parts.append("")
# 原始内容(如果有)
if content.transcript:
body_parts.append("## 原始内容")
body_parts.append("")
body_parts.append(content.transcript)
body_parts.append("")
# 无字幕提示
if not content.transcript:
body_parts.append("*此视频无字幕,仅保存元数据*")
body_parts.append("")
# 元数据
body_parts.append("---")
body_parts.append(f"- **来源**: [{content.author} - {content.title}]({content.url})")
if content.duration:
duration_str = self._format_duration(content.duration)
body_parts.append(f"- **时长**: {duration_str}")
body_parts.append(f"- **处理日期**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
return "\n".join(body_parts)