無駄と文化

実用的ブログ

徒歩旅実況ブログを支える技術

先日、高松市から徳島市までの約80kmを歩いて旅をした。

実際の経路と歩いた距離

こういう長距離の徒歩旅は以前にもやっていて、このブログに記事が残っている。

blog.mudatobunka.org

歩きながら写真を撮ったものにコメントをそえてまとめている。これらの記事を書くのが実はめちゃくちゃ面倒くさい。今回はこの面倒を技術の力で解決したので振り返ろう。

 

徒歩旅ブログの面倒くささ

徒歩旅のログは大体が下記3つの情報を並べたものになる。

  • 現在位置
  • その場の写真
  • ひとこと

写真を見返せばそのときの状況はそれなりに思い出せる、画像ファイルの Exif に位置情報が記録されているので撮った場所もわかる。

とはいえ、後になって書き起こして記事の文章にするのは面倒だ。

 

単に実況するだけなら X にポストしまくるだけでもいい。

しかし位置情報と紐づかないのが不満だ。 タイムラインがどんどん流れてしまうのも寂しい、やはり自分のブログに記事として残る安心感がほしい。

 

歩きながらブログを書く

ようは後からまとめ直すのが面倒ということで、歩きながらブログ記事も書けばいいじゃないか。 もっと云うと X で実況してるんだから、それがそのまま記事になってくれればいい。

というわけで「X へのポスト」と「ブログ記事への追記」を同時にできるフローを作った。

スマートフォンから、

  1. 写真を撮る
  2. 一言コメントを書く

すると

  • X に実況ポストされる
  • 同時にブログ記事にも追記される

という仕組みになっている。
裏側でスマホから現在位置を取得するので、何もせずに位置情報も残る。

 

構成

次の3つの要素からできている。

  • iOSショートカット
    • 歩きながら使う入力 UI 、写真・コメントの入力受け付けて捌く
  • iOS の「共有」で X にポスト
  • 自作APIサーバー
    • JSON で送られてきたデータを受け取る
    • AtomPub API で はてなブログの記事に追記する

イメージとしてはこんな流れ。

graph TD
    A[🚶‍♂️...] --> B[📱 iOSショートカット<br>(写真 / 位置 / コメント)]
    B --共有--> C[𝕏 にポスト]
    B --送信--> E[自作API]
    E --画像アップロード--> F[🖼️ はてなフォトライフ]
    E --> G[AtomPub API]
    F --> G
    G --> H[ブログ記事に追記]

X にポストする操作感で、手数を増やさずにブログ記事が随時更新されるフローを作れた。

 

iOS ショートカットの実装

ショートカットでは下記のようなタスクを順番にやっている。

やりたいこと 実際のアクション 補足
写真を用意する メニューから選択
カメラ or 写真を選択
今すぐ撮る/写真から選ぶの両方に対応
現在地とコメントを取得 場所 (現在地) + 入力を要求 X とブログに同じコメントを使う
X にポスト テキスト (ハッシュタグ, 記事 URL などを付与する)
リスト (写真とテキストをまとめる)
共有
共有から X を選んでポストする
送信データを作る 画像を変換 (HEIC→JPEG変換)
Base64エンコード
URL (GoogleマップのURL生成)
APIに送るデータの準備
サーバーに送る URLの内容を取得 JSON を組み立てつつ POST リクエスト
結果を通知する if文
通知を表示 (完了 or エラー)

こんな感じ。

多少面倒でもエラー通知はちゃんとやった方がいい。エラーに気づけないと記事に追記されないままになって後から面倒。

 

自作 API サーバーの実装

サーバーでは送られてきた画像をはてなフォトライフにアップロードし、指定されたブログ記事に追記更新している。

TypeScript で実装して Deno Depoly にホストした。
GitHub リポジトリと連携するだけでパブリック URL を発行してくれるし、ログや統計も最初から有効になっていて便利。

github.com

ソース一式がここにある。

 

ポイントをかいつまんで見てみよう。

はてなフォトライフもはてなブログも API でのコンテンツの投稿・更新に対応している。

developer.hatena.ne.jp

 

画像のアップロードはこうなる。

async function uploadPhoto(
  hatenaId: string,
  apiKey: string,
  base64: string,
): Promise<string> {
  // 画像ファイルの形式 (MIMEタイプ) を推測
  const mime = detectMimeType(base64);
  if (mime === null) {
    throw new Error("Unsupported image format. Supported: JPEG, PNG");
  }

  const title = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
  const body = `<?xml version="1.0" encoding="utf-8"?>
<entry xmlns="http://www.w3.org/2005/Atom">
  <title>${title}</title>
  <content type="${mime}">${base64}</content>
</entry>`;

  // WSSEで認証する
  const headers = await buildWsseHeaders(hatenaId, apiKey);
  headers.set("Content-Type", "application/atom+xml");

  // はてなフォトライフへ投稿
  const res = await fetch("https://f.hatena.ne.jp/atom/post/", {
    method: "POST",
    headers,
    body,
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Fotolife upload failed: ${res.status} ${text}`);
  }

  // 結果が XML で返る
  const xml = await res.text();
  return extractFotolifeSyntax(xml);
}

https://github.com/todays-mitsui/atompub-to-mudatobunka/blob/26b3fc3da748d2064e28fe5d795adc562ab78cf7/src/fotolife.ts#L32-L65

Base64 エンコードした画像データを XML に埋め込んで POST すると ID や URL などが返ってくる。
この ID をもとに フォトライフ記法 でブログ記事に画像を貼ることができる。

 

ブログ記事への追記はこうだ。

async function appendToEntry(
  hatenaId: string,
  apiKey: string,
  blogId: string,
  entryId: string,
  appendHtml: string,
): Promise<string | null> {
  const url = entryUrl(hatenaId, blogId, entryId);

  // 既存の記事を取得
  const getHeaders = await buildWsseHeaders(hatenaId, apiKey);
  const getRes = await fetch(url, { headers: getHeaders });

  if (!getRes.ok) {
    const text = await getRes.text();
    throw new Error(`Blog GET failed: ${getRes.status} ${text}`);
  }

  const xml = await getRes.text();
  const alternateLink = extractAlternateLink(xml);

  // 既存記事の本文を抜き出す
  const contentMatch = xml.match(/<content([^>]*)>([\s\S]*?)<\/content>/);
  if (!contentMatch) {
    throw new Error(`Failed to extract <content> from entry XML: ${xml}`);
  }
  const contentAttrs = contentMatch[1];

  // &gt; &amp; などの参照文字を解決
  const existingBody = unescapeXml(contentMatch[2]);

  // 既存本文の末尾にコンテンツを追記
  const newBody = existingBody + appendHtml;

  // <content> の中を追記後の本文で置き換える
  const now = new Date();
  const updatedXml = xml
    .replace(
      /<content([^>]*)>[\s\S]*?<\/content>/,
      `<content${contentAttrs}>${escapeXml(newBody)}</content>`,
    )
    .replace(
      /<updated>[^<]*<\/updated>/,
      `<updated>${now.toISOString()}</updated>`,
    );

  // 記事を更新
  const putHeaders = await buildWsseHeaders(hatenaId, apiKey);
  putHeaders.set("Content-Type", "application/atom+xml");
  const putRes = await fetch(url, {
    method: "PUT",
    headers: putHeaders,
    body: updatedXml,
  });

  if (!putRes.ok) {
    const text = await putRes.text();
    throw new Error(`Blog PUT failed: ${putRes.status} ${text}`);
  }

  // 更新した記事のパーマリンクを返す (完了通知で使う)
  return alternateLink;
}

https://github.com/todays-mitsui/atompub-to-mudatobunka/blob/26b3fc3da748d2064e28fe5d795adc562ab78cf7/src/blog.ts#L76-L136

はてなブログ AtomPub API に追記のためのエンドポイントがあるわけではない。
実際にやっているのは、既存記事の本文取得→末尾にコンテンツを結合→上書き更新 という処理だ。

 

ともかくこれにて、写真とコメントを送り付けるだけでアップロードや追記などを裏側でやってくれる。 処理時間は5~10秒ほどで、"瞬時に" とは云わないがまぁまぁ軽快。

 

まとめ

そうやって技術の支えを借りて歩きながら書いた記事がここにある。安心便利。

blog.mudatobunka.org

blog.mudatobunka.org

 

 

私からは以上です。