Atomフィードに対応した

自作ブログの記事をLAPRASに連携するためにAtomフィードに対応しました。

最初はRSS 2.0に対応しようかと思いましたが、Atomが一番シンプルそうなのでAtomに対応することにしました。前回の記事、Phoenixでブログを作った - FineCode Blogに引き続きやったことを書いていきます。

シンプルなAtomを配信

まずは RFC 4287 The Atom Syndication Format 日本語訳 にあるシンプルなXMLをPhoenixから配信するようにしました。 router.exにエンドポイントを追加します。

  scope "/", FinecodeWeb do
    pipe_through :browser

    get "/default", PageController, :default
    get "/", PageController, :index
    get "/about", PageController, :about
    # 以下を追加
    get "/feeds/atom.xml", FeedController, :atom
  end

これに合わせてController, View, Templateをそれぞれ配置していきます。

finecode_web/controllers/feed_controller.ex

defmodule FinecodeWeb.FeedController do
  use FinecodeWeb, :controller

  alias Finecode.Blog

  def atom(conn, _params) do
    render(conn, "atom.xml")
  end
end

finecode_web/views/feed_view.ex

defmodule FinecodeWeb.FeedView do
  use FinecodeWeb, :view
end

動的な要素はなしのフィードを生成。

finecode_web/templates/atom.xml.eex

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

  <title>FineCode</title>
  <link href="https://fine-code.com/"/>
  <updated>2003-12-13T18:30:02Z</updated>
  <author>
    <name>@yosu</name>
  </author>
  <id>https://fine-code.com</id>

  <entry>
    <title>Atom-Powered Robots Run Amok</title>
    <link href="http://example.org/2003/12/13/atom03"/>
    <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
    <updated>2003-12-13T18:30:02Z</updated>
    <summary>Some text.</summary>
  </entry>

</feed>

ここまでで /feeds/atom.xml にアクセスし、フィードが取得できることを確認。

ブログポストからフィードを生成

エンドポイントが問題なく表示できることがわかったので今度はブログ記事からフィードを生成していきます。 記事であるBlog.Postは以下の構造体になっているので、これをもとにフィードの各要素を作ります。

defmodule Finecode.Blog.Post do
  @enforce_keys [:id, :author, :title, :body, :description, :tags, :date]
  defstruct [:id, :author, :title, :body, :description, :tags, :date]

  # ...
end

updated要素の日時情報を作るのに以下のルールで作ることにしました。

  • Post.dateから日時への変換は日本時間(JST)の00:00 AM ということにする
  • feedのupdated要素は最新のBlog.Postの日付から生成する

FeedControllerを修正し、ViewやTemplateからBlog.Postが使えるようにします。

defmodule FinecodeWeb.FeedController do
  use FinecodeWeb, :controller

  alias Finecode.Blog

  def atom(conn, _params) do
    render(conn, "atom.xml", posts: Blog.list_posts())
  end
end

ここまででフィードは以下のようになります。

finecode_web/templates/atom.xml.eex

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

  <title>FineCode</title>
  <link href="https://fine-code.com/"/>
  <updated><%= @posts |> hd |> to_updated() %></updated>
  <author>
    <name>@yosu</name>
  </author>
  <id>https://fine-code.com</id>

  <%= for post <- @posts do %>
  <entry>
    <title><%= post.title %></title>
    <link href="<%= Routes.blog_url(@conn, :show, post.id) %>"/>
    <id><%= post.id %></id>
    <updated><%= to_updated(post) %></updated>
    <summary><%= post.description %></summary>
  </entry>
  <% end %>

</feed>

これであとはto_updated(%Post{} = post) を実装すればいいのですがこの実装方法で少し悩みました。

Dateからタイムゾーン付きのDateTimeへの変換

Dateからタイムゾーン情報を持つDateTime に変換したいのですが2つ問題がありました。

  • 標準ではUTCしかサポートしていない
  • Date から DateTime に素直に変換する方法がない

タイムゾーンをサポートするようにする

デフォルトだとUTC以外のタイムゾーンを使って DateTime を使おうとすると以下のようにエラーになります。

iex(20)> DateTime.now("Etc/UTC")
{:ok, ~U[2020-03-28 13:44:10.808908Z]}

iex(21)> DateTime.now("Asia/Tokyo")
{:error, :utc_only_time_zone_database}

ただ、これは標準のドキュメントに書いてある通り tzdata を導入すれば簡単に解決できます。

やることは以下の2つ。

mix.exs

  defp deps do
  [
    {:tzdata, "~> 1.0.3"}
  ]

config.ex

config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase

これでmix deps.get した後に iex -S mix で確認するとタイムゾーンが扱えるようになります。

  iex(1)> DateTime.now("Etc/UTC")
  {:ok, ~U[2020-03-28 13:49:17.007027Z]}

  iex(2)> DateTime.now("Asia/Tokyo")
  {:ok, #DateTime<2020-03-28 22:49:22.794612+09:00 JST Asia/Tokyo>}

DateからDateTimeへの変換

直接変換するようないいインタフェースがないので、結局NaiveDateTime 経由で変換することにしました。

Date -> 時間を付加 -> NaiveDateTime -> タイムゾーンを付加 -> DateTime

最初はまどろっこしいかと思ってたのですが、1ステップで1つのことだけやるので、結果的にシンプルかつ明瞭になりました。 実装は以下の通りです。

defmodule FinecodeWeb.FeedView do
  use FinecodeWeb, :view

  alias Finecode.Blog.Post

  def to_updated(%Post{} = post) do
    to_iso8601(post.date)
  end

  defp to_iso8601(%Date{} = date) do
    {:ok, naive_datetime} = NaiveDateTime.new(date, ~T[00:00:00])

    naive_datetime
    |> DateTime.from_naive!("Asia/Tokyo")
    |> DateTime.to_iso8601()
  end
end

ここまででほぼ完成したのでチェックしてみます。

フィードのチェック

フィードのチェックはW3Cのツールがあるのでこちらで行います。

W3C Feed Validation Service, for Atom and RSS

デフォルトはURL入力なのですが、フィードの本文をフォームに入力してチェックできるので、開発中はそちらでチェックします。 実際にチェックしてみると、3つエラーが出ました。

This feed does not validate.

line 12, column 20: id must be a full and valid URL: making-this-blog [help]

<id>making-this-blog</id>
                    ^
In addition, interoperability with the widest range of feed readers could be improved by implementing the following recommendations.

line 8, column 25: Identifier "https://fine-code.com" is not in canonical form (the canonical form would be "https://fine-code.com/") [help]

<id>https://fine-code.com</id>
                         ^
line 1, column 0: Missing atom:link with rel="self" [help]

<feed xmlns="http://www.w3.org/2005/Atom">

問題は、

  • entryのidがvalidなURLではない
  • feedのidがcanonical formではない
  • rel=”self” なlink要素がない

順に対応していきます。

id要素に対応する

id要素には適当に値をセットしてしまったんですが、調べて見るとid要素にはUUIDのような世界的にグローバルなIDを指定する必要がありました(実際Atom仕様の例ではUUIDがセットされている)。

また、UUIDの他にtag URIを利用するのも一般的なようでこちらを採用することにしました。 UUIDよりもヒューマンフレンドリー(人間が見たときに何のIDか分かりやすい)というのが理由です。

こちらの記事が分かりやすい。

結局以下のようなidを生成することにしました。

  • feedのid: fine-code.com,2020:feed
  • entryのid: fine-code.com,2020:entry:<記事のid>

2020年にfine-code.comドメイン所有者(つまり自分)のfeed, entryという意味のID。 記事のidは今回のこの記事であれば support-atom-feed になります。

ベースとなるtag URIをコンフィグに定義し、Viewにそれぞれのid生成するメソッドを追加します。

config/config.exs

# Base tag URI for Atom feed
config :finecode, :tag_uri, "tag:fine-code.com,2020"

finecode_web/views/feed_view.ex

defmodule FinecodeWeb.FeedView do
  def feed_id() do
    tag_uri() <> ":feed"
  end

  def entry_id(%Post{} = post) do
    tag_uri() <> ":entry:#{post.id}"
  end

  defp tag_uri() do
    Application.fetch_env!(:finecode, :tag_uri)
  end
end

rel=”self” なlink要素を追加

Atomフィード自身を指し示すlink要素が必要なので以下を追加します。

  <link rel="self" type="application/atom+xml" href="<%= Routes.feed_url(@conn, :atom) %>"/>

ただ、こちらを追加してもW3Cのチェッカーでは以下のような警告が出てしまいました。

line 4, column 90: Self reference doesn’t match document location [help]

デプロイ後にURLを入力してのチェックでは出なくなったので、フォームからのバリデーションでは出てしまう不具合のようです。

最終的なフィードテンプレート

finecode_web/templates/atom.xml.eex

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

  <title>FineCode</title>
  <link rel="alternate" type="text/html" href="<%= Routes.page_url(@conn, :index) %>"/>
  <link rel="self" type="application/atom+xml" href="<%= Routes.feed_url(@conn, :atom) %>"/>
  <updated><%= @posts |> hd |> to_updated() %></updated>
  <author>
    <name>@yosu</name>
  </author>
  <id><%= feed_id() %></id>

  <%= for post <- @posts do %>
  <entry>
    <title><%= post.title %></title>
    <link href="<%= Routes.blog_url(@conn, :show, post.id) %>"/>
    <id><%= entry_id(post) %></id>
    <updated><%= to_updated(post) %></updated>
    <summary><%= post.description %></summary>
  </entry>
  <% end %>

</feed>

最後にこのフィードへのメタタグを追加。

<link rel="alternate" href="<%= Routes.feed_url(@conn, :atom) %>" type="application/atom+xml" title="FineCode Atom Feed">

ここまでの修正を入れてデプロイ後にW3Cのチェッカーに今度は本番URLを入れて問題ないことが確認できました。

Feed Validator Results: https://fine-code.com/feeds/atom.xml

LAPRASに登録する

ここまで来たらLAPRASに登録してみます。

クロール対象サイト(ブログ) の記事の取得について | LAPRAS ヘルプ

こちらを参考にログイン後、連携編集、クロール対象サイトの追加からサイトのURLを登録します。 入力するのはフィードのURLではないことに注意。

ステータスが確認中になるのでしばらく待った所(1時間くらい)、 連携失敗の結果が出てしまいました。

最低限のフィードなのでvalidだけど連携に必要な要素が足りないのかもしれないです。 うーん。