esaでJekyllサイトのニュースを更新する

Posted by Shugo Maeda on December 15, 2023 · 14 mins read

NaClの前田です。

NaClの公式サイトを先日リニューアルしました。
サイト自体はJekyllで生成しているのですが、ニュースをesaで更新できるようにしたのでその方法を紹介します。

基本方針

esaには標準のGitHub Webhookがあり、それを使ってHeadless CMSとして使っている例が見つかりました。

ただ標準のWebhookはあまり細かい設定ができずJekyllと相性が悪かったり、削除にも対応していないようなので、Generic WebhookをAWS Lambdaで実装してGitHubリポジトリを更新することにしました。

Jekyll側の設定

なるべくJekyll側の設定は最小限にしたかったのですが、_postsのデフォルトのパスだと日付が含まれていて、esa側で日付を変更した場合の対応が厄介なので、stack overflowの記事を参考に_plugins/no_date.rbを作成して、記事のパスが news/_posts/<esaのページID>.md になるようにしました。

class Jekyll::PostReader
  # Don't use DATE_FILENAME_MATCHER so we don't need to put those stupid dates
  # in the filename. Also limit to just *.md, so it won't process binary
  # files from e.g. drafts.
  def read_posts(dir)
    read_publishable(dir, "_posts", /.*\.md$/)
  end

  def read_drafts(dir)
    read_publishable(dir, "_drafts", /.*\.md$/)
  end
end

以下のようにfront matterにesaの記事の日付を記載して、URLには日付が含まれるようにしています。

---
title: 中高生国際Rubyプログラミングコンテストに協賛
date: 2023-12-04
layout: news
---

記事の作成・更新

記事の作成・更新時には、octokit.gemを使ってGitHubリポジトリを更新します。
環境変数GITHUB_TOKENにはFine-grained personal access tokenを使用しています。

GITHUB_REPO = "NaCl-Ltd/www.netlab.jp"
GITHUB_REF = "heads/main"

github = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
sha_latest_commit = github.ref(GITHUB_REPO, GITHUB_REF).object.sha
sha_base_tree = github.commit(GITHUB_REPO, sha_latest_commit).commit.tree.sha
attachment_files, body =
  extract_attachment_files(github, number, content)
blob_content = <<EOF
---
title: #{title}
date: #{date}
layout: news
---

#{body}
EOF
sha = github.create_blob(GITHUB_REPO, [blob_content].pack("m"), "base64")
new_tree = [
  {
    :path => filename, 
    :mode => "100644", 
    :type => "blob", 
    :sha => sha
  },
  *attachment_files
]
sha_new_tree = github.create_tree(GITHUB_REPO, new_tree,
                                  { base_tree: sha_base_tree }).sha
sha_new_commit = github.create_commit(GITHUB_REPO, commit_message,
                                      sha_new_tree, sha_latest_commit).sha
github.update_ref(GITHUB_REPO, GITHUB_REF, sha_new_commit)

extract_attachment_filesでは画像などの添付ファイルを抽出してBLOBを作成しています。

def extract_attachment_files(github, number, content)
  esa = Esa::Client.new(access_token: ENV["ESA_TOKEN"], current_team: "nacl")
  attachment_files = []
  body = content.gsub(%r'https://(files|dl).esa.io/uploads/[^\s")]+') { |url|
    basename = File.basename(url)
    path = "attachments/news/#{number}/#{basename}"
    url = esa.signed_url(URI.parse(url).path).body["url"]
    data = Net::HTTP.get(URI(url))
    sha = github.create_blob(GITHUB_REPO, [data].pack("m"), "base64")
    attachment_files << {
      :path => path,
      :mode => "100644", 
      :type => "blob", 
      :sha => sha
    }
    "/#{path}"
  }
  return attachment_files, body
end

記事の削除

create_treeに { base_tree: sha_base_tree } を渡さずに、削除したいファイルを除外したnew_treeを指定すれば削除できるという記事があったのですが、この方法では削除されませんでした。

base_tree = github.tree(GITHUB_REPO, sha_base_tree, recursive: true)
new_tree = base_tree.tree.filter_map { |blob|
  !%r'\A(news/_posts/#{number}\.md\z|attachments/news/#{number}/)'.match?(blob.path) &&
    {
      path: blob.path,
      mode: blob.mode,
      type: blob.type,
      sha: blob.sha
    }
}
sha_new_tree = github.create_tree(GITHUB_REPO, new_tree).sha

削除したいファイルのshaにnullを指定すればよいという記事があったので以下のように修正したところ記事の削除もできました。

base_tree = github.tree(GITHUB_REPO, sha_base_tree, recursive: true)
new_tree = base_tree.tree.filter_map { |blob|
  %r'\A(news/_posts/#{number}\.md\z|attachments/news/#{number}/)'.match?(blob.path) &&
    {
      path: blob.path,
      mode: blob.mode,
      type: blob.type,
      sha: nil
    }
}
sha_new_tree = github.create_tree(GITHUB_REPO, new_tree,
                                  { base_tree: sha_base_tree }).sha

なお、当初はWebhookの設定でフィルタを指定すると記事の削除時にWebhookが呼ばれない問題がありましたが、Xで愚痴をこぼしたところesaの不具合だったようで修正されました。ありがとうございました。

esaの設定例

esaの設定例

ソースコード全体

require "openssl"
require "json"
require "rack"
require "octokit"
require "esa"

GITHUB_REPO = "NaCl-Ltd/www.netlab.jp"
GITHUB_REF = "heads/main"

def lambda_handler(event:, context:)
  unless valid_signature?(event)
    return { statusCode: 400, body: "Invalid signature" }
  end
  esa_event = JSON.parse(event["body"])
  log(esa_event: esa_event)
  year, month, day, title =
    esa_event["post"]["name"].scan(%r"\APublic/News/(\d+)/(\d+)/(\d+)/(.*)")[0]
  if title.nil?
    log("Ignore: #{esa_event['post']['name']}")
  else
    number = esa_event["post"]["number"]
    kind = esa_event["kind"]
    case kind
    when "post_create", "post_update"
      if esa_event["post"]["wip"]
        log("WIP: do nothing")
      else
        filename = "news/_posts/#{number}.md"
        date = "#{year}-#{month}-#{day}"
        content = esa_event["post"]["body_md"].gsub(/\r\n/, "\n")
        post_news_entry(kind, number, filename, date, title, content)
      end
    when "post_delete"
      delete_news_entry(number, title)
    end
  end
  { statusCode: 200, body: "Success!" }
end

def valid_signature?(event)
  body = event["body"]
  sig = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"),
                                ENV["ESA_SECRET"], body)
  header_sig = event["headers"]["x-esa-signature"].delete_prefix("sha256=")
  if Rack::Utils.secure_compare(sig, header_sig)
    true
  else
    log_error("Invalid signature: #{sig} != #{header_sig}")
    false
  end
end

def post_news_entry(kind, number, filename, date, title, content)
  verb = kind.slice(/post_(.*)/, 1).capitalize + "d"
  msg = "#{verb} via esa webhook: number=#{number} title=#{title}"
  commit(msg) do |github, sha_base_tree|
    attachment_files, body =
      extract_attachment_files(github, number, content)
    blob_content = <<EOF
---
title: #{title}
date: #{date}
layout: news
---

#{body}
EOF
    sha = github.create_blob(GITHUB_REPO, [blob_content].pack("m"), "base64")
    [
      {
        :path => filename, 
        :mode => "100644", 
        :type => "blob", 
        :sha => sha
      },
      *attachment_files
    ]
  end
end

def extract_attachment_files(github, number, content)
  esa = Esa::Client.new(access_token: ENV["ESA_TOKEN"], current_team: "nacl")
  attachment_files = []
  body = content.gsub(%r'https://(files|dl).esa.io/uploads/[^\s")]+') { |url|
    basename = File.basename(url)
    path = "attachments/news/#{number}/#{basename}"
    url = esa.signed_url(URI.parse(url).path).body["url"]
    data = Net::HTTP.get(URI(url))
    sha = github.create_blob(GITHUB_REPO, [data].pack("m"), "base64")
    attachment_files << {
      :path => path,
      :mode => "100644", 
      :type => "blob", 
      :sha => sha
    }
    "/#{path}"
  }
  return attachment_files, body
end

def delete_news_entry(number, title)
  msg = "Deleted via esa webhook: number=#{number} title=#{title}"
  commit(msg) do |github, sha_base_tree|
    base_tree = github.tree(GITHUB_REPO, sha_base_tree, recursive: true)
    base_tree.tree.filter_map { |blob|
      %r'\A(news/_posts/#{number}\.md\z|attachments/news/#{number}/)'.match?(blob.path) &&
        {
          path: blob.path,
          mode: blob.mode,
          type: blob.type,
          sha: nil
        }
    }
  end
end

def commit(commit_message)
  github = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])

  sha_latest_commit = github.ref(GITHUB_REPO, GITHUB_REF).object.sha
  sha_base_tree = github.commit(GITHUB_REPO, sha_latest_commit).commit.tree.sha
  new_tree = yield(github, sha_base_tree)
  log(new_tree: new_tree)
  sha_new_tree = github.create_tree(GITHUB_REPO, new_tree,
                                    { base_tree: sha_base_tree }).sha
  sha_new_commit = github.create_commit(GITHUB_REPO, commit_message,
                                        sha_new_tree, sha_latest_commit).sha
  github.update_ref(GITHUB_REPO, GITHUB_REF, sha_new_commit)
end

def log(msg = nil, **params)
  puts(msg) if msg
  puts(params.to_json) if !params.empty?
end

def log_error(msg)
  STDERR.puts(msg)
end

まとめ

esaのGeneric Webhookを実装することで、Jekyll側にあまり手を入れずに記事の更新・削除ができるようになりました。
Generic Webhookは便利なのでみなさんも活用してみてください。