やっぱりブログを運営してるなら OGP画像 に記事のタイトルとか入れて Twitter とかで表示させたいよね。
ということで実装しました。

完成品

かわいい

コーヒー豆

前提

  • Hugoでブログを作っていて、各記事はディレクトリごとに分かれていてすべて index.md で管理している
  • 記事のディレクトリは content/posts/YYYY/YYMMDD_HHmmss/index.md という形
  • OGP用の画像は content/posts/YYYY/YYMMDD_HHmmss/ogp.png という形で保存する

ざっくり内容

  • ChatGPTといっしょにコーディング
    • GoでOGP画像を生成するコードを書いてもらった
  • 記事を書いた / タイトルを更新した 場合に go run ./main.go で実行すれば画像が生成される
  • OGPの生成を毎回全記事でやってたら途方もない時間かかるので、OGP用のHTMLをハッシュ化してキャッシュ
    • ハッシュが一致したら画像の生成をスキップする

OGP生成周りのコード ディレクトリ構成

/ogp-generator
  |--bg.jpg
  |--go.mod
  |--go.sum
  |--main.go
  |--templates
  |  |--ogp.html

さいごに: コード

ほぼChatGPTにGoのコードを書いてもらいました。
なぜGoを選んだのかというと、自分が全く触ったことがないので書いてもらったうえで読み込んで理解したかったからですね。

// main.go
package main

import (
	"bufio"
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"gopkg.in/yaml.v3"
	"html/template"
	"io/ioutil"
	"log"
	"net/url"
	"os"
	"path/filepath"
	"time"

	"github.com/chromedp/chromedp"
)

type PostData struct {
	Title           string
	BackgraundImage string
}

type FrontMatter struct {
	Title string `yaml:"title"`
}

func main() {
	imagePath := "./bg.jpg"

	// 画像ファイルを読み込む
	imageData, err := ioutil.ReadFile(imagePath)
	if err != nil {
		log.Fatalf("画像の読み込みに失敗しました: %v", err)
	}
	base64Image := base64.StdEncoding.EncodeToString(imageData)

	contentDir := "../content/posts"

	// テンプレートを読み込む
	tmpl, err := template.ParseFiles("templates/ogp.html")
	if err != nil {
		log.Fatalf("テンプレートの読み込みに失敗: %v", err)
	}

	// 投稿フォルダを走査
	err = filepath.Walk(contentDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		if info.IsDir() {
			mdFile := filepath.Join(path, "index.md")

			title, _ := getPostTitle(mdFile)

			if _, err := os.Stat(mdFile); err == nil {
				post := PostData{
					Title:           title,
					BackgraundImage: base64Image,
				}

				var buf bytes.Buffer
				if err := tmpl.Execute(&buf, post); err != nil {
					log.Printf("テンプレートの実行に失敗: %v", err)
					return nil
				}

				htmlContent := buf.String()
				navigateURL := "data:text/html;charset=utf-8," + url.PathEscape(htmlContent)

				// キャッシュファイルのパス
				cachePath := filepath.Join(path, "ogp_cache")

				// キャッシュをチェック
				if cacheMatches(cachePath, navigateURL) {
					log.Printf("キャッシュが一致しました。スキップ: %s", path)
					return nil
				}

				// キャッシュと一致しない場合、OGP画像を生成
				outputPath := filepath.Join(path, "ogp.jpeg")
				if err := generateImage(navigateURL, outputPath); err != nil {
					log.Printf("OGP画像生成失敗: %v", err)
				} else {
					log.Printf("OGP画像生成成功: %s", outputPath)
					// キャッシュを更新
					if err := updateCache(cachePath, navigateURL); err != nil {
						log.Printf("キャッシュ更新失敗: %v", err)
					}
				}
			}
		}
		return nil
	})

	if err != nil {
		log.Fatalf("投稿フォルダの処理に失敗: %v", err)
	}
}

// キャッシュをチェックする関数
func cacheMatches(cachePath string, navigateURL string) bool {
	// navigateURLをハッシュ化
	hashedURL := hashString(navigateURL)

	cacheData, err := ioutil.ReadFile(cachePath)
	if err != nil {
		// ファイルが存在しない場合もあるためエラーを無視
		return false
	}
	return string(cacheData) == hashedURL
}

// キャッシュを更新する関数
func updateCache(cachePath string, navigateURL string) error {
	// navigateURLをハッシュ化
	hashedURL := hashString(navigateURL)
	return ioutil.WriteFile(cachePath, []byte(hashedURL), 0644)
}

// ハッシュ化する関数
func hashString(input string) string {
	hash := sha256.Sum256([]byte(input))
	return hex.EncodeToString(hash[:]) // ハッシュ値を16進数文字列に変換
}

func generateImage(navigateURL string, outputPath string) error {
	opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true))
	ctx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
	defer cancel()

	ctx, cancel = chromedp.NewContext(ctx)
	defer cancel()

	ctx, cancel = context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	var buf []byte
	if err := chromedp.Run(ctx,
		chromedp.Navigate(navigateURL),
		chromedp.EmulateViewport(1200, 630),
		chromedp.WaitVisible("body", chromedp.ByQuery),          // 5秒待機を追加
		chromedp.Screenshot("body", &buf, chromedp.NodeVisible), // スクリーンショットを強制取得
	); err != nil {
		log.Fatalf("スクリーンショット取得失敗: %v", err)
	}

	if err := os.WriteFile(outputPath, buf, 0644); err != nil {
		return err
	}
	return nil
}

// Markdownファイルの Front Matter からタイトルを取得
func getPostTitle(mdPath string) (string, error) {
	file, err := os.Open(mdPath)
	if err != nil {
		return "", err
	}
	defer file.Close()

	var fm FrontMatter
	scanner := bufio.NewScanner(file)
	inFrontMatter := false
	yamlContent := ""

	for scanner.Scan() {
		line := scanner.Text()
		if line == "---" {
			if !inFrontMatter {
				inFrontMatter = true
			} else {
				break
			}
		} else if inFrontMatter {
			yamlContent += line + "\n"
		}
	}

	if err := scanner.Err(); err != nil {
		return "", err
	}

	err = yaml.Unmarshal([]byte(yamlContent), &fm)
	if err != nil {
		return "", fmt.Errorf("YAMLパースエラー: %v", err)
	}

	return fm.Title, nil
}
// ogp.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ .Title }}</title>
</head>
<body style="
    width: 1200px;
    height: 630px;
    margin: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: 'Hiragino Sans', Arial, sans-serif;
    background: linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7)), /* 半透明の黒 */
                url('data:image/jpeg;base64,{{ .BackgraundImage }}');
    background-size: cover; /* 画像を全体に拡大縮小してフィット */
    background-position: center; /* 中央寄せ */
    color: #3e3e3e;
    text-align: center;
    overflow-wrap: break-word;
">
<div style="
        width: 1000px;
        padding: 20px;
        word-break: break-word; /* 長い単語を折り返す */
        white-space: normal;    /* 自動で改行を許可 */
        line-height: 1.5;      /* 行間を調整して読みやすくする */
    ">
    <h1 style="
            font-size: 36px;
            line-height: 1.2;
        ">
        {{ .Title }}
    </h1>
    <h1 style="
            font-size: 24px;
            line-height: 1.2;
        ">
        - SoraRikuLog -
    </h1>
</div>
</body>
</html>