Zenn
😸

Zennの記事をNotionで管理する

2025/03/22に公開

背景

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の連携は以下のような流れで行います。

  1. [Notionで記事作成]
  2. [自動変換: Notion API → mdファイル] ←このプログラムはここを担当
  3. [GitHubにアップロード]
  4. [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("![image](%s)\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

ログインするとコメントできます