| @ブログ

カテゴリー検索

ふと思い立ってカテゴリーで検索できるようにしたら便利だろうと改修してみた。 Tantiny には facet_query というものがあって、カテゴリー名などは検索インデックスの型を facet にしておくと、カテゴリーだけを検索対象にできる。実装してみたところ便利。カテゴリー内の記事一覧ページを表示する機能はあるが、本文やコメントなども読み込むため遅い。タイトルだけ一覧でがっと欲しいときにカテゴリー検索は便利。

同様にタグでも検索できるようにしてみた。こちらは term_query を使っている。本文の検索は検索キーワードを形態素解析してトークナイズされた文字列で検索しているが、タグ名での検索は exact match をするようにしている。なので過不足あればヒットしない。完全一致で検索したいときに便利。

ただ、これまで記事にタグを設定してこなかったのでタグ未設定の記事が多い。ということで ChatGPT に記事の内容を読ませてタグを自動生成してもらうようにした。ちょっとへんちくりんなタグを設定することもあるが、文脈を読んでタグを設定してくれる。本文中にキーワードはないけど記事の内容に合致するタグを選んできたりもする。賢い。

ちなみに今回 ChatGPT のモデルを gpt-4o から gpt-5-mini に変更した。 GPT 5 系に移行するためには Chat API から Responses API への移行が必要だった。最初それがわからず難儀したが、 Responses API の方がパラメーターがシンプルで使いやすい。例えば JSON のフォーマットを指定したいとき、 Chat API だと JSON Schema で定義を書いて渡す必要があったが、 Response API であればプロンプト中に「こういうフォーマットでくれ」と書けばそのフォーマットで返してくれる。すごい。

require 'openai'

class EntryTagGenerator
  MODEL = 'gpt-5-mini'
  PROMPT = <<~EOF
以下のブログ記事に関して、次の作業をしてください。

1. この文章の内容を反映したタグを抽出してください。タグの数は最大で 5 個までとします。タグは極力一語で構成されるようにし、略語は避けてください。また「減量ダイエット」のような意味が重複する二つの単語で構成されるタグは付与しないでください。固有名詞の場合はその限りではありません。

レスポンスは JSON フォーマットで、以下のような形式にしてください。

{
  "tags": ["tag1", "tag2"...]
}

# ブログ記事本文

EOF

  def initialize(content)
    @content = content
    @client = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
  end

  def generate
    request = @client.responses.create(
      parameters: {
        model: MODEL,
        input: input,
        reasoning: { effort: 'minimal' }
      }
    )
    JSON.parse(request.dig("output", 1, "content", 0, "text").strip)
  rescue => e
    puts "タグ生成エラー: #{e.message}"
    nil
  end

  def input
    %(#{PROMPT}#{@content})
  end
end

| @技術/プログラミング

去年の 3 月に ChatGPT を API で使いたいと思って、 OpenAI の API クレジットを $10 分買っていた。 ChatGPT の API を使っても全然クレジットが減らず、まだ $9.5 分くらい残ってたはずだが、先日買いたブログ記事の要約を作ってもらおうとしたがちゃんと動かない。どうも API がレスポンスを返していないようだ。最初はぶっ壊れたのかなと思ったが、 ChatGPT に聞いてみると以下の通りで、購入から 1 年が経ったら失効するとのことだった。

CleanShot 2025-04-24 at 00.26.51@2x

利用頻度は高くないけれど使えなくなったら不便なので取り急ぎ $5 分クレジットを購入した。

| @ブログ

OpenAI の API を利用してブログの本文の要約を自動生成する機能をつけてみた。ブログの編集画面に要約欄を追加し、チェックボックスにチェックが入っていれば OpenAI の API にリクエストを投げて記事本文の要約を生成して保存するようにした。

要約自動生成君

これも ChatGPT に設計を依頼してレビュー&手直ししながらやった。めっちゃ便利。要約生成君のコードはこんな感じ。

require 'openai'

class EntrySummarizer
  MODEL = 'gpt-4o-mini'

  def initialize(content)
    @content = content
    @client = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
  end

  def summarize
    response = @client.chat(
      parameters: {
        model: MODEL,
        messages: [
          { role: "system", content: "以下のブログ記事を著者になりきって、日本語で簡潔に要約してください。文章の長さは200文字以内で、受動態表現と「ですます」調を避けて下さい。" },
          { role: "user", content: @content }
        ],
        temperature: 0.3,
        max_tokens: 150
      }
    )
    response.dig("choices", 0, "message", "content").strip
  rescue => e
    puts "要約生成エラー: #{e.message}"
    nil
  end
end

| @ブログ

OGP の og:image を動的に生成する機能をブログに実装していた( 1 年半も前)。

記事本文中に画像がある記事であれば og:image は本文中に含まれる最初の画像を og:image として設定するようにしている。画像がない文章だけの記事の場合はこれまでサイトのロゴを og:image として表示していた。それだと金太郎飴っぽくなってしまうので、はてなブログとか Qiita とかがやってるみたいに、タイトルとサイトロゴを使って動的に og:image を生成して表示することにした。

こだわりポイントとしては、日本語のタイトルの折り返し位置をいい感じにするために形態素解析して、ちょうどいい折り返し位置を決定するような処理を実装した。この辺のコードは結構頑張ってる。

def nm
  @nm ||= Natto::MeCab.new(
    userdic: File.expand_path('lib/tokenizer/userdic.dic'),
    node_format: "%M\t%H\n",
    unk_format: "%M\t%H\n"
  )
end

def prepare_text(text:)
  splitted_text = nm.enum_parse(text).map(&:feature)
  row_length = 0
  result = []
  do_loop = true
  while do_loop do
    splitted_text.each.with_index(1) do |item, i|
      result[row_length] ||= ''
      if (result[row_length].length + item.length) > INDENTION_COUNT
        row_length += 1
        result[row_length] = ''
      end
      result[row_length] += item
      do_loop = false if splitted_text.length == i
    end
    do_loop = false if ROW_LIMIT - 1 > row_length
  end
  result.each {|item| item.gsub!(/EOS\n\z/, '') }
  if result[-1].length == 1
    result[-2] += result[-1]
    result.pop
  end
  result.map(&:strip).join("\n").gsub(/"/, '\"').chomp
end

結果はこんな感じになる。

実際に動的に生成されたこの記事の og:image

| @ブログ

Photo Gallery

写真でふりかえるやつはフォトギャラリーとして実装した。

  1. PhotoSwipe を使えるように組み込む
  2. PhotoSwipe が期待するフォーマットで本文の HTML を書き出しつつ写真を S3 にアップロードする Rake タスクを作成
  3. PhotoSwipe の Dynamic Caption プラグインを組み込む
  4. ↑の Rake タスクから exiftool を呼び出し、 Exif データを HTML 内に Caption として埋め込む
  5. ↑の Rake タスクに緯度経度から住所を取得する機能を追加( Nominatim の API を利用)

ChatGPT に聞きながらやったらサクッとできてしまった。 AI 、ちょっとやる気がある人がその気になればサクッとプログラムを作れてすごい。 Rails で 10 分で Twitter もどきを作れる〜みたいなのがどんな種類のプログラムでもできる感じ。すごい世の中になった。


フォトギャラリーの機能が面白くて、むかし Flickr の存在を知ったときに猿のように画像をアップロードしてた頃を思い出した。あの頃はウェブ上に写真の Exif 情報が表示されるだけで面白かった。カメラの機種名とかレンズとか、焦点距離とか絞り値とか。自分のブログでそういうのをサクッと表示できてうれしい。 2025 年はもっと写真を撮ってブログにアップしていきたい。こういう発言、ウェブ縄文時代( 2000 年代)っぽい。

Flickr に関してはダメなサービスになってしまったし、悪口のような記事も過去に書いたけれど、 Flickr があるおかげで写真を撮ってアップロードするのが楽しかった側面はあったなぁと思う。 Flickr を使わなくなってから全く一眼カメラを使わなくなってしまった。

フォトギャラリーを作ったので、今後はまめにカメラを引っ張り出して写真を撮りたい。

| @ブログ

これから毎月写真でふりかえってみることにする。

SNS に写真を上げるだけではなく、自分で見え方まで考えて写真を整理してみたくなった。 Flickr に写真を上げてアルバムを作っていたようなことを自分のブログでやる感じ。

ギャラリーのライブラリは Medium Zoom だと足りなかったので PhotoSwipe に移行してみた。

| @技術/プログラミング

このブログは Ruby 2.7 でずっと動かしていた。コミットログをたどると 2020 年の 1 月から Ruby 2.7 のようだ。 Ruby 2.7 は 2023 に EOL を迎えている。

さすがにまずいと思ったので Ruby 3 にしようと一日頑張ってみたが、なかなかうまくいかない。Ruby 3 の キーワード引数の仕様変更はかなり対応がきつい。どこで ArgumentError が起こっているのかが極めて追いかけづらい。他のメソッドに委譲している場合などは特に。

ガチャガチャやってトップページと個別記事ページまでは Ruby 3 化できたので Ruby 3 でデプロイしてみたが、動かない画面があることに気がついたので Ruby 2.7 に戻した。 Kaminari がちゃんと動かない(具体的には kaminari-sinatra と actionview v6 系の互換性がない)のが原因でページネーションするページがちゃんと動かなそうだったので Ruby 3 化は諦めた。 kaminari-sinatra の ActionViewTemplateProxy#initialize を actionview v6 対応させないと無理っぽい。

kaminari のような有名な gem の派生 gem ならきちんとメンテされてるかなーと思っていたが、 kaminari-sinatra の最終コミットは 4 年前だった。

Ruby で View まで作る人たちはほとんどいなくなってるのだろう。

なお動かないところのデバッグは ChatGPT と対話しながらやった。めっちゃ便利。一人だと気がつかないような部分のコードを見てみろと ChatGPT が言ってくれて、そこにデバッグコードを入れてみるとビンゴだったりする。便利な世の中になった。

忘れないようにやったこと・気づいたことをメモっておく。

  • sinatra は v4 にあげないといけないのでパスの正規表現から ^$ は消さないといけない
  • better_errors の REPL がちゃんと動かないので Backtrace を見たいときはログを開くか better_errors を使うのをやめる
  • fork していた sinatra-cache は sinatra 4 では動かないので外した(キャッシュできない部分をどうするかは要検討)
  • capistrano-puma も Ruby 3 対応させないといけない(期待される systemd のフォーマットが変わっているので単にデプロイするだけではだめで一部手作業が必要)
  • tilt は v2.1.0 に固定( Tilt::ErubisTemplate クラスが消えるため、 padrino-helper がエラーを出す)
  • concurrent-ruby は 1.3.5 未満に固定
  • compass は 1.0.3 に固定
  • SESSION_SECRET は 64 文字以上にする
  • haml の過剰な escape を抑制
  • kaminari-sinatra の ActionViewTemplateProxy#initialize を actionview v6.1.7.8ActionView::Base#initialize に対応させる