😸
Zennの記事をNotionで管理する
背景
Zennの記事をローカルのmdファイルで管理していると、こんな問題に直面しませんか?
- Zennの仕様上、ファイル名に日本語が使えないため、記事が増えるほど管理が煩雑になる。
- IDEで検索できるとはいえ、確認したい時にVSCodeを起動するのが面倒に感じる。
- 「あの記事、どこに書いたっけ?」と探す時間がもったいない。
これらの課題を解決するために、私はNotionとNotion APIを使った記事管理に移行しました。
Notionの自由度の高いデータベース機能を使えば、Zennの記事をより効率的に管理できます。さらに、Notion APIを使えば、Notionで作成した記事を自動でmdファイルに変換し、Zennにアップロードすることも可能です。
この記事では、Notionを活用してZennの記事を効率的に管理する方法をご紹介します。
なぜNotionなのか?(ローカル管理での課題とNotionのメリット)
ローカルのmdファイルでZennの記事を管理する場合、以下のような課題があります。
- ファイル名制限: Zennの仕様上、ファイル名に日本語が使えないため、記事が増えるほど管理が煩雑になります。article1.md、article2.md…大量のファイル名から目的の記事を見つけるのは困難です。
- IDEの起動コスト: IDEで検索できるとはいえ、ちょっとした確認のためにVSCodeを起動するのが面倒に感じることもあります。
- 検索性の問題: ファイル名だけでは記事の内容をすぐに思い出せないため、ファイルを開いて確認する手間が発生します。
Notionのメリット
- 柔軟なタイトル管理: タイトルに日本語が使えるため、記事内容が分かりやすく管理できます。
- 優れた検索機能: Notionの強力な検索機能により、過去の記事を瞬時に見つけられます。
- 一元管理: 記事の作成、更新、削除がNotion上で完結し、ファイル管理の手間を省けます。
- どこからでもアクセス可能: クラウドベースなので、PCだけでなくスマホやタブレットからもアクセスできます。実際にこの記事は外出先で作成しました。
Notion API連携による自動化 - 実装手順の詳細
NotionとZennの連携は以下のような流れで行います。
- [Notionで記事作成]
- [自動変換: Notion API → mdファイル] ←このプログラムはここを担当
- [GitHubにアップロード]
- [Zennで自動公開]
コード例 (Go言語)
- topicsは記事の本文をインプットに「GEMINI API」で自動生成
- 変数は以下を設定してください。
GEMINI_API_KEY・・・GeminiのAPIトークン
NOTION_TOKEN・・・NotionのAPIトークン
NOTION_PAGE_ID・・・記事化したいページの一覧ページ(親ページ)のID - go run main.go で実行
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"github.com/google/generative-ai-go/genai"
"github.com/joho/godotenv"
"github.com/jomei/notionapi"
"google.golang.org/api/option"
)
// NotionPageInfo はNotionページの情報を格納する構造体です。
type NotionPageInfo struct {
Title string
MarkdownBody string
}
// FileContent はファイルに書き込む内容を格納する構造体です。
type FileContent struct {
Title string
Emoji string
Type string
Topics []string
Published bool
Body string
}
func main() {
// .envファイルから環境変数をロード
err := godotenv.Load("./docker/env/.env.local")
if err != nil {
log.Fatalf("Error loading .env file: %v", err)
}
// 環境変数からAPIキーを取得
notionToken := os.Getenv("NOTION_TOKEN")
notionPageID := os.Getenv("NOTION_PAGE_ID") // 環境変数からNotionページIDを取得
geminiAPIKey := os.Getenv("GEMINI_API_KEY") // 環境変数からGemini APIキーを取得
// 出力先フォルダ
outputDir := "zenn-content/articles/"
// Notionクライアントの初期化
notionClient := notionapi.NewClient(notionapi.Token(notionToken))
// Geminiクライアントの初期化
ctx := context.Background()
client, err := genai.NewClient(ctx, option.WithAPIKey(geminiAPIKey))
if err != nil {
log.Fatalf("Error creating Gemini client: %v", err)
}
defer client.Close()
// 指定されたページのサブページを取得
subpageIDs, err := getSubpageIDs(notionClient, notionPageID)
if err != nil {
log.Fatalf("Failed to get subpage IDs: %v", err)
}
// 各サブページを処理
for _, subpageID := range subpageIDs {
// Notionページから情報を取得
pageInfo, err := getNotionPageInfo(notionClient, subpageID)
if err != nil {
log.Printf("Failed to get Notion page info for page %s: %v", subpageID, err)
continue // 次のページに進む
}
// Markdownファイルを作成
filename := subpageID + ".md"
outputPath := filepath.Join(outputDir, filename)
var topics []string
// ファイルがすでに存在するか確認
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
// ファイルが存在しない場合は新規作成
// Geminiを使ってトピックを生成
topics, err = generateTopics(ctx, client, pageInfo.MarkdownBody)
if err != nil {
log.Printf("Failed to generate topics for page %s: %v", subpageID, err)
continue // 次のページに進む
}
newFileContent := FileContent{
Title: pageInfo.Title,
Emoji: "😸",
Type: "tech",
Topics: topics,
Published: true,
Body: pageInfo.MarkdownBody,
}
err = createFile(outputPath, generateFileContent(newFileContent))
if err != nil {
log.Printf("Failed to create file %s: %v", outputPath, err)
} else {
log.Printf("Created file %s", outputPath)
}
} else {
// ファイルが存在する場合は内容を比較
existingFileContent, err := getFileContent(outputPath)
if err != nil {
log.Printf("Failed to get file content for %s: %v", outputPath, err)
continue // 次のページに進む
}
// 行頭と行末の改行と空白を削除
newBody := strings.Trim(pageInfo.MarkdownBody, " \n") // Notionから取得した本文を使用
existingBody := strings.Trim(existingFileContent.Body, " \n")
// 本文とタイトルが一致する場合スキップ
if existingBody == newBody && existingFileContent.Title == pageInfo.Title { // Notionから取得したタイトルを使用
log.Printf("Skipped file %s (content and title are identical)", outputPath)
continue // 次のページに進む
}
// Geminiを使ってトピックを生成
topics, err = generateTopics(ctx, client, pageInfo.MarkdownBody)
if err != nil {
log.Printf("Failed to generate topics for page %s: %v", subpageID, err)
continue // 次のページに進む
}
newFileContent := FileContent{
Title: pageInfo.Title,
Emoji: "😸",
Type: "tech",
Topics: topics,
Published: true,
Body: pageInfo.MarkdownBody,
}
// 更新が必要な場合のみ上書き
err = updateFile(outputPath, generateFileContent(newFileContent))
if err != nil {
log.Printf("Failed to update file %s: %v", outputPath, err)
} else {
log.Printf("Updated file %s", outputPath)
}
}
}
fmt.Println("Finished processing all subpages.")
}
// getSubpageIDs は指定されたページのサブページのIDを取得します。
func getSubpageIDs(client *notionapi.Client, pageID string) ([]string, error) {
children, err := getAllBlocks(client, pageID)
if err != nil {
return nil, fmt.Errorf("failed to get children blocks: %w", err)
}
var subpageIDs []string
for _, child := range children {
switch b := child.(type) {
case *notionapi.ChildPageBlock:
subpageIDs = append(subpageIDs, string(b.ID))
}
}
return subpageIDs, nil
}
// getNotionPageInfo は Notion API を使用して、指定されたページのタイトルと Markdown 本文を取得します。
func getNotionPageInfo(client *notionapi.Client, pageID string) (*NotionPageInfo, error) {
page, err := client.Page.Get(context.Background(), notionapi.PageID(pageID))
if err != nil {
return nil, fmt.Errorf("failed to get page: %w", err)
}
title := ""
properties := page.Properties
for _, prop := range properties {
if prop.GetType() == notionapi.PropertyTypeTitle {
titleProp := prop.(*notionapi.TitleProperty)
if len(titleProp.Title) > 0 {
title = titleProp.Title[0].PlainText
}
break // 最初のTitleプロパティを見つけたらループを抜ける
}
}
// ブロックを取得
blocks, err := getAllBlocks(client, pageID)
if err != nil {
return nil, fmt.Errorf("failed to get blocks: %w", err)
}
// Markdownに変換
markdownBody := convertBlocksToMarkdown(blocks)
return &NotionPageInfo{
Title: title,
MarkdownBody: markdownBody,
}, nil
}
// getAllBlocks は Notion API を使用して、指定されたページのすべてのブロックを再帰的に取得します。
func getAllBlocks(client *notionapi.Client, pageID string) ([]notionapi.Block, error) {
var allBlocks []notionapi.Block
var cursor notionapi.Cursor = ""
for {
query := notionapi.Pagination{
StartCursor: cursor,
PageSize: 100, // 最大100
}
resp, err := client.Block.GetChildren(context.Background(), notionapi.BlockID(pageID), &query)
if err != nil {
return nil, fmt.Errorf("failed to get children blocks: %w", err)
}
allBlocks = append(allBlocks, resp.Results...)
if resp.HasMore {
cursor = notionapi.Cursor(resp.NextCursor)
} else {
break
}
}
return allBlocks, nil
}
// convertBlocksToMarkdown は Notion のブロックを Markdown 形式に変換します。
func convertBlocksToMarkdown(blocks []notionapi.Block) string {
var markdown strings.Builder
for _, block := range blocks {
switch b := block.(type) {
case *notionapi.ParagraphBlock:
for _, richText := range b.Paragraph.RichText {
markdown.WriteString(richText.PlainText)
}
markdown.WriteString("\n\n")
case *notionapi.Heading1Block:
markdown.WriteString("# ")
for _, richText := range b.Heading1.RichText {
markdown.WriteString(richText.PlainText)
}
markdown.WriteString("\n\n")
case *notionapi.Heading2Block:
markdown.WriteString("## ")
for _, richText := range b.Heading2.RichText {
markdown.WriteString(richText.PlainText)
}
markdown.WriteString("\n\n")
case *notionapi.Heading3Block:
markdown.WriteString("### ")
for _, richText := range b.Heading3.RichText {
markdown.WriteString(richText.PlainText)
}
markdown.WriteString("\n\n")
case *notionapi.BulletedListItemBlock:
markdown.WriteString("* ")
for _, richText := range b.BulletedListItem.RichText {
markdown.WriteString(richText.PlainText)
}
markdown.WriteString("\n")
case *notionapi.NumberedListItemBlock:
markdown.WriteString("1. ") // シンプルにするため全て1.から開始
for _, richText := range b.NumberedListItem.RichText {
markdown.WriteString(richText.PlainText)
}
markdown.WriteString("\n")
case *notionapi.QuoteBlock:
markdown.WriteString("> ")
for _, richText := range b.Quote.RichText {
markdown.WriteString(richText.PlainText)
}
markdown.WriteString("\n\n")
case *notionapi.CodeBlock:
markdown.WriteString("```" + b.Code.Language + "\n") // 言語情報を付与
for _, richText := range b.Code.RichText {
markdown.WriteString(richText.PlainText)
}
markdown.WriteString("\n```\n\n")
case *notionapi.DividerBlock:
markdown.WriteString("---\n\n")
case *notionapi.ImageBlock:
markdown.WriteString(fmt.Sprintf("\n\n", b.Image.File.URL)) // 画像URLをそのまま記述
default:
// 他のブロックタイプは無視
fmt.Printf("Unsupported block type: %T\n", b)
}
}
return markdown.String()
}
// generateTopics は Gemini API を使用して、与えられたテキストから関連するトピックを生成します。
func generateTopics(ctx context.Context, client *genai.Client, text string) ([]string, error) {
model := client.GenerativeModel("gemini-2.0-flash") // モデル名を修正
prompt := fmt.Sprintf("このテキストに関連する技術単語を5つ生成してください。有名な固有の技術単語でお願いします。結果はJSON形式の配列で返してください: %s", text)
resp, err := model.GenerateContent(ctx, genai.Text(prompt)) // promptをgenai.Textとして渡す
if err != nil {
return nil, fmt.Errorf("failed to generate topics: %w", err)
}
if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
return nil, fmt.Errorf("no response from Gemini")
}
contentPart := resp.Candidates[0].Content.Parts[0]
// 型アサーションが成功するか確認
contentText, ok := contentPart.(genai.Text)
if !ok {
return nil, fmt.Errorf("unexpected response type from Gemini: %T", contentPart)
}
content := string(contentText) // genai.Textをstringに変換
// 前処理: ```json と改行文字を削除
content = strings.ReplaceAll(content, "```json", "")
content = strings.ReplaceAll(content, "\n", "")
content = strings.TrimSpace(content) // 前後の空白を削除
// 末尾の ``` を削除
content = strings.TrimSuffix(content, "```")
var topics []string
err = json.Unmarshal([]byte(content), &topics)
if err != nil {
// JSON Unmarshalに失敗した場合、カンマ区切りでパースを試みる
content = strings.ReplaceAll(content, "[", "")
content = strings.ReplaceAll(content, "]", "")
content = strings.ReplaceAll(content, "\"", "")
topicList := strings.Split(content, ",")
// 空文字を削除
var result []string
for _, topic := range topicList {
topic = strings.TrimSpace(topic) // 前後の空白を削除
if topic != "" {
result = append(result, topic)
}
}
topics = result
if len(topics) == 0 {
return nil, fmt.Errorf("failed to parse topics: %w", err)
}
log.Println("JSON Unmarshalに失敗しましたが、カンマ区切りでパースしました。")
}
return topics, nil
}
// generateFileContent はファイルの内容を生成します。
func generateFileContent(content FileContent) string {
// トピックを文字列に変換
topicsStr := `["` + strings.Join(content.Topics, `", "`) + `"]`
// テンプレートの作成
return fmt.Sprintf(`---
title: "%s"
emoji: "%s"
type: "%s"
topics: %s
published: %t
---
%s`, content.Title, content.Emoji, content.Type, topicsStr, content.Published, content.Body)
}
// getFileContent はファイルからFileContentを読み込みます。
func getFileContent(filename string) (FileContent, error) {
var content FileContent
file, err := os.Open(filename)
if err != nil {
return content, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// ファイルの内容を読み込む
fileContent, err := io.ReadAll(file)
if err != nil {
return content, fmt.Errorf("failed to read file: %w", err)
}
// ファイル内容を文字列に変換
contentString := string(fileContent)
// Contentを分割
parts := strings.SplitN(contentString, "---", 3)
if len(parts) != 3 {
return content, fmt.Errorf("invalid file format")
}
// YAML部分を解析
yamlPart := parts[1]
// YAMLを解析
lines := strings.Split(yamlPart, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
keyValue := strings.SplitN(line, ":", 2)
if len(keyValue) != 2 {
continue
}
key := strings.TrimSpace(keyValue[0])
value := strings.TrimSpace(keyValue[1])
switch key {
case "title":
content.Title = strings.Trim(value, "\"")
case "emoji":
content.Emoji = strings.Trim(value, "\"")
case "type":
content.Type = strings.Trim(value, "\"")
case "topics":
// JSON形式の文字列をパース
err := json.Unmarshal([]byte(value), &content.Topics)
if err != nil {
return content, fmt.Errorf("failed to unmarshal topics: %w", err)
}
case "published":
if value == "true" {
content.Published = true
} else {
content.Published = false
}
}
}
// Body部分を設定
content.Body = strings.TrimSpace(parts[2])
return content, nil
}
// calculateHash は文字列のSHA256ハッシュを計算します。
func calculateHash(s string) string {
h := sha256.New()
h.Write([]byte(s))
return hex.EncodeToString(h.Sum(nil))
}
// getFileHash はファイルのSHA256ハッシュを取得します。
func getFileHash(filename string) (string, error) {
f, err := os.Open(filename)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// createFile はファイルを作成し、内容を書き込みます。
func createFile(filename string, content string) error {
dir := filepath.Dir(filename)
if _, err := os.Stat(dir); os.IsNotExist(err) {
err = os.MkdirAll(dir, 0755)
if err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
}
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
_, err = file.WriteString(content)
if err != nil {
return fmt.Errorf("failed to write to file: %w", err)
}
return nil
}
// updateFile はファイルの内容を上書きします。
func updateFile(filename string, content string) error {
file, err := os.OpenFile(filename, os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
_, err = file.WriteString(content)
if err != nil {
return fmt.Errorf("failed to write to file: %w", err)
}
return nil
}
// compareStringSlices は2つのstringスライスが等しいかどうかを比較します。
func compareStringSlices(slice1, slice2 []string) bool {
if len(slice1) != len(slice2) {
return false
}
for i, v := range slice1 {
if v != slice2[i] {
return false
}
}
return true
}
フォルダ構成
/プロジェクトルート
│ go.mod
│ go.sum
│ main.go
├─.devcontainer
│ devcontainer.json
├─docker
│ │ docker-compose.local.yml
│ ├─env
│ │ .env.local
│ └─services
│ └─go
│ Dockerfile.local
└─zenn-content // 記事のアウトプットフォルダ
まとめ
- この記事では、Notionを活用してZennの記事を効率的に管理する方法をご紹介しました。
- Notion APIを使った記事管理は、少し手間がかかるかもしれませんが、一度設定してしまえば、その後の記事執筆が格段に楽になります。
ぜひNotionを使った記事管理を試してみてください!
今後の展望
- 更新があるかの判定を本文比較ではなく、一覧ページの更新時間から判定する(トークン消費・ネットワーク負荷の削減)
Discussion