| @ブログ

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

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

バッチ処理を動かしている Docker コンテナ含めてすべての環境を Ruby 3 で動かせるようになった。以下の点に難儀した。

  • MeCab が Google Drive からダウンロードできなくなっているので代替を探した
  • Tantiny が依存する rutie という Rust と Ruby をブリッジする gem が新しめの Rust に対応しておらず、 Rust のバージョンを 1.77 に固定する必要があった
  • ActiveRecord が v6 に上がったことにより、 DATABASE_URL を環境変数で渡すことで DB 接続設定を上書きできなくなってしまった
    • 設定ファイルの方を優先して読み込むようだった

ついでにキャッシュも効くように修正した。 sinatra-cache がおかしかったのは Haml の挙動が変わって - form_tag としていたところを = form_tag とする必要があるのと同様に、 - cache_fragment= cache_fragment にする必要があった。再びキャッシュが効くようになって高速になったが、一部 HTML タグが混ざって表示されることがある。 sinatra-cache.gem が依存する sinatra-outputbuffe.gem の方に問題がありそう。この gem は 16 年以上更新されていない。どこかでキャッシュ依存はやめないといけないかもしれない。

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

先週末と今日ガチャガチャやって、ようやく Ruby 3 にアップグレードすることができた。 Ruby 2.7.3 → Ruby 3.1.6 。ただ Ruby 3.1 は今年の 5 月に EOL を迎えるみたいなのでこちらもさっさと新しいバージョンの Ruby に上げないといけない。

やったことは一つ前の記事に加えて以下。

kaminari-sinatra の SinatraHelpers が Ruby 3 & ActionView v6 対応していなかったのでちょこちょこと修正した。

次に padrino-helpers が Ruby 3 と Haml v6 に対応していないのを対応させた。具体的には form_tag の中身のタグが過剰に escape されてしまうので、あんまり良くないかもだが capture_html したやつを html_safe した。 form_tag の内側に来るものはユーザー投稿コンテンツではないはずなのでエスケープはサイト管理者側でできるはず。

ドキュメントでは

= form_tag

!= form_tag

にしろとは言われているが、 form_tag の中身で concat_contet してる片方( capture_html(&block) の結果)が html_safe? => false になるので、 View テンプレート側で何かやっても意味がない( Buffer が汚染されると View で html_safe しても汚染された部分の文字列はエスケープ済みになっている)。

kaminari-sinatra も padrino-helpers も本家にパッチを送ると良いのだろうが、職業プログラマーではなくなったのでなかなか腰が重い。 kaminari-sinatra はテストが通らないし、 padrino-helpers は git clone で submodule の clone に失敗するのでテストが実行すらできないかもしれない。

心の余裕ができたらやってみる。

ちなみに Ruby 3 化するにあたり sinatra-cache を完全に捨てたので負荷が上がるかも。オリジナルの gem は 15 年くらいコミットされてなくて fork して使い続けてきたけど Sinatra や Haml の変更に追従できる気がしないのでいったん捨ててみる。

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

このブログは 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 に対応させる

| @雑談

天山食堂

しばらくブログを書いてなくてなんか執筆のエンジンがかからないのでただの日記でも。

運転免許の更新だった。今回、 10 年ぶりにゴールド免許に復帰できたので南区の試験場まで行かずに済んだ。最近、渡辺通のサンセルコから千代に移動してきた県の合同庁舎内にある千代ゴールド免許センターに行った。

千代県庁口駅は地下鉄空港線沿線ではなく、中洲川端で貝塚線に乗り換えないといけない。なので遠くはないがちょっと行くのが面倒だ。

駅に着いて合同庁舎まで 3, 4 分歩く。千代の街にはほとんど来ない。福岡市民は天神のアクロスでパスポートが取れるので、県庁に来ることも基本的にないんじゃないだろうか。

合同庁舎前に着くと懐かしい感じの名称が。以前博多駅の筑紫口にあって、勉強会でよく通っていた福岡県Ruby・コンテンツ産業振興センターがここに移動してきていた。免許の更新が目的ではあったがRubyコンテンツセンターの看板をパシャリと撮った。

福岡県Ruby・コンテンツ産業振興センター

ゴールド免許センターは二階だった。優良講習は事前予約制で、予約したときに表示される QR コードを保存して持ってきて端末にかざさないといけない。 QR コードをキャプチャして Fantastical に保存していたのに表示されなくて焦る。 Fantastical の iPhone アプリは添付ファイルを開けない仕様だったのを思い出した。 iOS 標準カレンダーは添付ファイルを開けたが、今度は電波のつながりが悪くてなかなか画像が表示されない。自分が端末を操作する直前になって画像が読み込まれ、 QR コードを無事機械に読み取らせることができた。高齢の人にはこれは難しいだろう。事実、年寄りの人には係員の人がずっと付き添っていて渋滞していた。

優良講習の内容はいつもの内容に加えて、最近は電動キックボードが制度として組み込まれたことと、自転車のヘルメット着用が努力義務化されたことを伝えられた。前の人の頭が邪魔であまり映像がよく見えず集中できなかった。

講習が終わって新しい免許証を受け取ってもまだ 10:20 くらいだった。仕事は午後からやろうと決めていた。渡辺通のゴールド免許センターだったら講習後に寄れる店がいくらでもあるけど、千代はなかなか店がない。朝は急いでいて朝食を抜いていたのでお腹がすいている。どこか入ろうと思ったが時間が中途半端すぎる。合同庁舎の横にスターバックスがあったので、そこに入ってとりあえず時間を潰せばよかったのに、火と人という西部ガスがやってそうなレストランが気になり駅に戻ったら、ランチは 11 時からでまだ食事はできなかった。いよいよ店に入るには中途半端な時間になった。せっかく来たのだからと店名が気になった天山食堂という食堂に行くことにした。 Google マップで写真を見ると実に店構えがいい。 15 分前に店に着いたのでコンビニ行ってコーヒーを買って時間を潰す。千代のローソンはすさんでいて、ゴミ箱は持ち込みゴミ禁止の警告がペタペタと貼られ、ゴミ箱の投入口は木の板を貼られて加工され、小さなゴミしか捨てられなくなっていた。監視カメラ撮影中と至る所に掲示があったが、どこにも監視カメラは設置されてなさそうだった。カフェラテを買って車止め柵にもたれかかった飲む。朝は紅茶しか飲んでなかったので初めてカロリーを摂取する。寒かったのでほっとする。

コーヒーを飲み終わると 11:00 になっていたので天山食堂へ移動する。自分の前に一人先客がいて二番目だった。場所柄、韓国風の店なのかと思っていたが、店内は普通の和食の店という感じ。メニューに韓国風のものは豚キムチくらい。天山はきっと韓国の山の名前なのではないかと思っていたが、そうではないようだった。佐賀にゆかりがある人がやってる店なのかもしれない。

天山食堂のメニュー

Google マップや Instagram の投稿を見ると、焼肉定食を頼んでご飯をチャーハンに変更するのが通の頼み方のようだが、裏メニューでメニューに書いていないものを一見の客が頼むのは気恥ずかしく、普通に焼肉定食を頼んだ。比較的すぐ出てきた。付け合わせの野菜炒めがとてもうまかった。厨房でガコンガコンと中華鍋を振る音が聞こえたので本格的に炒めてあるのだと思う。ラードで強火を使い短時間で炒めてあり、野菜はシャキシャキしていた。肉は甘辛い味付けで、昔ながらの家で食べる焼肉という感じだった。小鉢の冷や奴が印象に残っている。豆腐の上にネギと鰹節がかかっていた。味噌汁もちゃんとだしをとったやつでうまかった。これで 800 円。最近は福岡も外食の値段が上がっているので、ほとんど手作りでこの値段は安い。 Google マップを見ると以前はもっと安かったみたいだ。

天山食堂の焼肉定食

分量もちょうど良く、食べ終えてあたたかいお茶を飲み、店を出た。ここから歩いて呉服町までも行ける。呉服町は以前のオフィスがあったところなので行ってみるのも一興だったが、午後の仕事が気になったのでおとなしく千代県庁口駅まで戻って地下鉄を乗り継いで会社へ向かった。孤独のグルメのような一日だった。

| @Mac/iPhone

iA Writer

iPad Air を買ったことで iPad で文章を書くときに何を使うか考え始めた。 iOS と iPad で iA Writer は同一アプリとなっているようで、 iPhone 用に買った iA Writer をそのまま iPad Air でも使うことができた。

iPad Air で iA Writer を使ってみて、ファイル管理っぽい機能があることに初めて気が付いた。 iOS では小さく表示されるのでそんな機能があることに気がつかなかった。それで Mac 版を試してみたところ結構良かったので、ブログなどの文章書きを Vim から iA Writer に乗り換えることにした。

iA Writer は iPhone では 6 年くらい前から使ってる。その頃は 1200 円くらいで買えたがいまはめっちゃ値上がりしてて 8000 円もする1( iPhone 用アプリで 8000 円ですよ!)。ドルベースの価格も上がっているが円安が効いていて高くなっている(ドルでの価格は 49.99 ドル)。

iA Writer for iOS は電車の中で文章を編集するのに使ってた。通勤してた頃、混んだ電車の中でパソコンを取り出せず、 iPhone の iA Writer で Vim で書き起こしたポエムの続きを書いたりしてた。

まぁまぁ便利だったのだが、やっぱりスマートフォンのキーボードでは書きづらいしまぁこんなもんかなぁと思いながら、どうしても iPhone で文章を編集したいときだけ起動するような使い方をしていた。

iPad Air で iA Writer を使ってみて、ファイル管理っぽい機能があることに初めて気が付いた。

iA Writer のファイル管理機能

スマートフォルダという機能もあって、バラバラに散らばったファイルをいい感じに整理できることにも気が付いた。 Apple Music のスマートプレイリストみたいな機能だ。 macOS の Finder の機能を転用してるのだと思う。

iA Writer のスマートフォルダ

"メモを取り、書き、編集するための集中できる環境。それ以外には何もありません。"

iA Writer のウェブサイトではフルスクリーン表示できて文章を書くことに集中できることをウリにしているが、自分にとってはファイルを一覧表示・管理できる機能(ファイラー)がめっちゃ便利だと思った。これまで Vim + memolist で書いた文章は一定期間経過したら Day One に取り込みつつ、年ごとアーカイブ用フォルダーを作って Ruby スクリプトでそこに待避していたが、そんなことをする必要なく iA Writer だけでファイルの管理ができてしまう(サブディレクトリに移動させたいファイルをドラッグ&ドロップで移動させればよい)。

Day One のカレンダーで過去の記事を見られる機能は便利だが、Day One はプレーンテキストを捨てて Markdown ベースの独自仕様リッチテキストに移行してしまったし、起動に時間がかかるのも微妙だと思っていて、シンプルにプレーンテキストのファイルとして管理できる iA Writer の方がよい気がしてきた。

Day One のカレンダー UI

iA Writer の良いところを列挙するとこんな感じ。

  1. 起動が速い
    起動してすぐ書き始められる
  2. ファイル管理機能
    Finder とテキストエディターが統合されたような使い勝手
  3. クイック検索
    Vim の Unite 検索のような機能があり、めっちゃ素早く対象のディレクトリ以下のファイルを検索できる

これまで Vim を使い続けてきたのは Unite での検索が便利すぎたからだった。メモ書きディレクトリに移動してテキトーに Vim を起動して UniteGrep すれば書きかけのファイルをぱっと探せる。

iA Writer には UniteGrep と似たクイック検索という機能があって、しかもショートカットで検索 UI を呼び出せるところが良い( + + o)。

iA Writer のクイック検索

Day One にも似たような検索機能はあるものの、検索窓を呼び出すショートカットがない。いちいちマウスカーソルを動かして🔍マークをタップしないと検索できない。検索窓はマウスやトラックパッドに手を持ち替えることなく表示されないとダメだと思う(一つ前の記事参照

デバイス間のファイル同期は iCloud を使っている。複数の端末で同時に開いてもコンフリクトが起こることはほぼなくファイルが同期される。敬虔な Apple 信者でいる限りファイルの同期のために Evernote や Notion を使う必要はない。

いまは Obsidian や Roam Research のような新手のソフトもあるようだが、自分の場合は iA Writer で満足している。使っていないが Wiki Link 記法も使えるのでファイル同士の繋がりを作ることもできるようだ。

職業プログラマーになって以来、テキストエディターは Vim 一択だろうという気持ちでいたが、 GUI でファイル検索・管理できるという一点で iA Writer を気に入ってしまった。コードを書くならいまでも Vim が良いが、ブログの文章や個人用メモは完全に iA Writer に乗り換えてしまった。世間的に大人気な Notion を使っていたらこのようにエディターを他のものに乗り換えることはできなかったのでプレーンテキスト万歳だ。


ピアソン版の『達人プログラマー』に GUI のファイル検索だと目的のファイルが見つかるのに時間がかかるので grepfind でファイルを探す方が高速だ、という記事がある(第3章)。 Unix 哲学的な話で、小さな機能を持つソフトを組み合わせて使う方が効率的だという話だった。 grepfind で検索して xargs -o vim するより、テキストエディターの中で一覧表示出来る方が便利だ。少なくとも普通の人にとっては。何でもシェルでやるのが良いという時代は終わったのかもしれない。

とはいえ iA Writer は macOS の仕組みの上で達人プログラマー的なやり方を継承しているようにも感じる。例えば先ほどのスマートフォルダは macOS の Finder にある機能だ。ひょっとしたら UI が同じだけでまるっきり別の実装がしてあるかもしれないが、 OS や SDK が提供しているシンプルな GUI を組み合わせて便利なものを作るのが今風の達人プログラマーなのかもしれない。


  1. Mac 版は同一ライセンスになっておらず Mac App Store から別途購入する必要がある。 8000 円は高いので一か月くらい悩んで買った。