読書ブログAI開発記・第4回|写真付きの記事をBloggerへ届ける ― メールとAPIで下書きを仕上げる
コードは今回のAndroidアプリの実装から抜粋し、一部は説明用に短く整理しています。単独で完成アプリになるコードではなく、第3回までの記事生成処理と、Googleの認可を受けたアプリを前提にしたものです。個人のブログURL、投稿用アドレス、記事ID、認証情報は掲載していません。
GmailApiClient、BloggerApiClient、BloggerEmailPostComposer、BloggerPendingPostStoreは今回作った自作クラスです。記事のHTMLができても、写真には置き場所が必要
アプリで選んだ写真は端末内にある。その写真をブログの読者にも見せるには、ブログから参照できる場所へ渡す必要がある。そこで今回採用したのが、写真をBloggerのメール投稿へ送り、その後をBlogger APIで仕上げる方法だった。
メール投稿先はBloggerの設定に対応する専用アドレスを使う。アプリにはブログURLと投稿用メールアドレスを設定し、写真付きメールの送信にはGmail APIを使う構成になっている。
先にBlogger側を「下書きとして保存」にする
Bloggerの設定にあるメール投稿を有効にし、受信したメールを下書きとして保存する設定を選ぶ。今回の処理は下書きを検索するため、即時公開の設定とは組み合わせない。写真をメールへ添付して投稿する方法も公式ヘルプに案内されている。Blogger公式ヘルプ:メールによる投稿
設定値を説明用に書くと、次のようになる。これは実際の送信先ではなく、置き換える場所を示す例である。
ブログURL: https://example.com/
投稿用メールアドレス: blogger-post@example.invalid
メール投稿の扱い: 下書きとして保存Gmail送信とBlogger操作の許可を受ける
アプリではGoogleの認可処理を使い、メール送信時にはGmail送信とBlogger操作のスコープを要求する。Bloggerの反映を確認するだけの場面では、Blogger側のスコープを要求している。実装で指定している文字列は次の2つで、個人のIDや秘密鍵ではない。
private const val BLOGGER_SCOPE =
"https://www.googleapis.com/auth/blogger"
private const val GMAIL_SEND_SCOPE =
"https://www.googleapis.com/auth/gmail.send"
今回のプロジェクトはGoogle Cloud側でGmail API・Blogger APIを有効にし、Android用OAuth設定を済ませて使う構成である。以下のaccessTokenには認可結果を渡す。APIキーだけを渡したり、アカウントのパスワードをコードへ書いたりする形ではない。
認可画面と結果の受け取り処理はここでは省略する。コードで使うのは、AndroidのUri・Base64、org.json.JSONObject、JavaのHTTP通信、KotlinのCoroutinesなど。自作クラス名と、ライブラリが提供するクラス名を分けて読むと追いやすい。
写真を渡してから、同じ下書きの本文を更新する
写真付き記事の処理は、次の順番で進む。
- 記事データから、写真URLの差し込み位置を持つHTMLを作る。
- 識別番号を付けた写真付きメールをGmail APIで送信する。
- Blogger側に対応する画像付き下書きができるのを待つ。
- 下書きからBloggerの画像URLを取得する。
- 写真URLをHTMLへ差し込み、同じ下書きの記事タイトルと本文を更新する。
メールには識別番号を持たせている。単に新しい記事を探すだけでは、ほかの下書きと混ざる可能性があるためだ。自分が送った写真に対応する下書きを見つけてから更新へ進む。
写真URLは、あとから差し込む
第3回で組み立てたHTMLは、最初の時点ではBloggerの画像URLが分からない。そこで、写真の位置へ専用の目印を入れておく。次は実装の方式を短くした例である。
const val COVER_IMAGE_PLACEHOLDER = "__BOOKBLOGAI_COVER_IMAGE_URL__"
// 実際には、第3回の記事生成処理で作成した本文を使う。
val htmlTemplate = """
<article class="book-review">
<img src="$COVER_IMAGE_PLACEHOLDER" alt="本の写真">
<h1>サンプル小説を読んで</h1>
<p>ここに清書した感想が入ります。</p>
</article>
""".trimIndent()
// 記事ごとに生成して、待機情報と一緒に保存する。
val uploadToken = "BOOKPHOTO-${java.util.UUID.randomUUID()}"
識別番号はメールの件名と本文に入れる。この番号が、後でBloggerの下書きを探すときの目印になる。本文を完成記事に更新した後は、識別用の仮タイトルも正式な記事タイトルへ置き換わる。
写真付きメールは、MIME形式で組み立てる
実装ではContentResolverで選択写真を読み取り、本文と写真を含むメールを作る。次はJPEG画像1枚に絞って短くしたサンプル関数。完成版には画像サイズの上限確認と、PNGなどの形式に応じた処理もある。
import android.util.Base64
import java.util.UUID
fun buildPhotoMail(
to: String,
uploadToken: String,
jpegBytes: ByteArray
): ByteArray {
require(to.isNotBlank() && '\r' !in to && '\n' !in to)
require(Regex("[A-Za-z0-9-]+").matches(uploadToken))
require(jpegBytes.isNotEmpty())
val boundary = "photo-${UUID.randomUUID()}"
val text = "写真付き記事を準備しています。\n識別番号: $uploadToken"
val body64 = Base64.encodeToString(
text.toByteArray(Charsets.UTF_8), Base64.CRLF
).trimEnd('\r', '\n')
val image64 = Base64.encodeToString(
jpegBytes, Base64.CRLF
).trimEnd('\r', '\n')
val lines = listOf(
"To: $to",
"Subject: $uploadToken",
"MIME-Version: 1.0",
"Content-Type: multipart/mixed; boundary=\"$boundary\"",
"",
"--$boundary",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: base64",
"",
body64,
"--$boundary",
"Content-Type: image/jpeg",
"Content-Transfer-Encoding: base64",
"Content-Disposition: attachment; filename=\"book-cover.jpg\"",
"",
image64,
"--$boundary--",
""
)
return lines.joinToString("\r\n").toByteArray(Charsets.UTF_8)
}メール全体をbase64urlにして、Gmail APIへ渡す
MIME内の添付画像には通常のBase64を使い、メール全体はbase64urlに変換してJSONのrawへ入れる。この送信形式はGmail APIの公式仕様に沿っている。Gmail API公式:メールの作成と送信
// buildPhotoMail()で作ったメール全体を変換する。
val mimeBytes = buildPhotoMail(postingEmailAddress, uploadToken, jpegBytes)
val raw = android.util.Base64.encodeToString(
mimeBytes,
android.util.Base64.URL_SAFE or
android.util.Base64.NO_WRAP or
android.util.Base64.NO_PADDING
)
val body = org.json.JSONObject().put("raw", raw)
// 次のrequestJson()をCoroutine内から呼び出す。
val response = requestJson(
method = "POST",
url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
accessToken = accessToken,
body = body
)
val messageId = response.optString("id")
check(messageId.isNotBlank()) { "メール送信結果にIDがありません。" }
このmessageIdはGmailのメールIDで、Bloggerの記事IDとは別である。両者を取り違えず、Blogger側では識別番号から下書きを探す。
HTTP通信の共通部分
APIとのやり取りを見通しよくするため、実装のHTTP処理を短い共通関数に整理した。完成版はGmail用・Blogger用それぞれで詳しいエラー表示を行っているが、この例ではHTTPステータスを確認して結果を返す。
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
suspend fun requestJson(
method: String,
url: String,
accessToken: String,
body: JSONObject? = null
): JSONObject = withContext(Dispatchers.IO) {
val connection = URL(url).openConnection() as HttpURLConnection
try {
connection.requestMethod = method
connection.connectTimeout = 15_000
connection.readTimeout = 60_000
connection.setRequestProperty("Authorization", "Bearer $accessToken")
connection.setRequestProperty("Accept", "application/json")
if (body != null) {
connection.doOutput = true
connection.setRequestProperty(
"Content-Type", "application/json; charset=UTF-8"
)
val bytes = body.toString().toByteArray(Charsets.UTF_8)
connection.setFixedLengthStreamingMode(bytes.size)
connection.outputStream.use { it.write(bytes) }
}
val status = connection.responseCode
check(status in 200..299) { "API通信に失敗しました: HTTP $status" }
val text = connection.inputStream.bufferedReader(Charsets.UTF_8)
.use { it.readText() }
if (text.isBlank()) JSONObject() else JSONObject(text)
} finally {
connection.disconnect()
}
}
通信はDispatchers.IOで実行する。これを呼ぶ画面側はCoroutineを使い、処理中表示やボタン制御を担当する。通信の待ち時間を、そのまま画面操作を止める時間にしない構成である。
メール送信成功と、記事完成は別の状態
この連携で必要になったのが、待機中の表示と確認処理だった。メールを送れた瞬間に、Blogger側の画像付き下書きまで使えるとは限らない。
下書きがまだなければ待機を続ける。下書きがあっても写真URLを取得できなければ、記事完成にはしない。メール送信、下書き確認、本文更新という段階を、それぞれの状態として扱うようにした。
画面上でも、書誌情報を確認しているのか、AIが清書しているのか、写真を送っているのか、Bloggerを待っているのかが分かる。待ち時間がある処理ほど、今どこまで進んだのかという表示が効いてくる。
対応する下書きを探す
完成版では、認証したGoogleアカウントのブログ一覧を取得し、設定したブログURLに一致するブログのIDを使う。そのブログの下書きを取得して、件名または本文に識別番号が含まれるものを探す。
以下はBloggerApiClient内の実装の抜粋。apiUrl()はBlogger APIのURLを作る自作メソッド、requestJson()は操作名付きのクラス内HTTPメソッドである。上の説明用共通関数とは引数が異なる。
suspend fun findDraftByUploadToken(
accessToken: String,
blogId: String,
uploadToken: String
): DraftPost? = withContext(Dispatchers.IO) {
val url = apiUrl("blogs/$blogId/posts")
.appendQueryParameter("status", "draft")
.appendQueryParameter("fetchBodies", "true")
.appendQueryParameter("orderBy", "updated")
.appendQueryParameter("maxResults", "50")
.appendQueryParameter("view", "ADMIN")
.appendQueryParameter("fields", "items(id,title,content)")
.build()
.toString()
val response = requestJson("下書き一覧取得", "GET", url, accessToken)
val items = response.optJSONArray("items") ?: return@withContext null
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val title = item.optString("title")
val content = item.optString("content")
if (title.contains(uploadToken) || content.contains(uploadToken)) {
return@withContext DraftPost(
id = item.getString("id"),
title = title,
content = content
)
}
}
null
}
DraftPostは次の小さなデータ型で、同じクラス内に定義している。
data class DraftPost(
val id: String,
val title: String,
val content: String
)
今回の実装は更新順の下書きを最大50件取得し、その範囲で照合する。全ページを巡回する実装ではない。新しく送った写真の下書きを探す個人用の処理として、この範囲で組み立てた。
写真URLを取り出して、記事へ差し込む
下書きのHTMLから写真のURLを取り出す。完成版の抽出処理は、srcやhrefから候補を集め、HTTPSか、画像のホスト名やサイズ表記はどうかを調べて選ぶ。返されるHTMLには小さな表示用画像とリンク先画像があるため、単に最初の文字列を使う処理にはしていない。
差し込み部分だけなら、次の関数になる。写真URLはHTML属性の値としてエスケープしておく。
import android.text.TextUtils
fun insertCoverImageUrl(htmlTemplate: String, imageUrl: String): String {
val marker = "__BOOKBLOGAI_COVER_IMAGE_URL__"
require(htmlTemplate.contains(marker)) { "写真の差し込み位置がありません。" }
return htmlTemplate.replace(marker, TextUtils.htmlEncode(imageUrl.trim()))
}
同じ記事IDへPUTし、タイトルと本文を更新する
写真付き下書きの記事IDを指定して、完成HTMLへ更新する。新規記事を追加する呼び出しではなく、既存記事の更新である。Blogger API公式:Posts.update
HTTP部分を上の共通関数に合わせて整理すると、次のようになる。blogIdとdraftIdはAPIの取得結果を使う。個人の記事IDをコードに固定する必要はない。
// Coroutine内の処理。タイトルとHTMLは第3回の生成結果を使う。
val updateUrl = android.net.Uri.Builder()
.scheme("https")
.authority("www.googleapis.com")
.appendPath("blogger").appendPath("v3")
.appendPath("blogs").appendPath(blogId)
.appendPath("posts").appendPath(draftId)
.appendQueryParameter("fetchBody", "true")
.build().toString()
val updated = requestJson(
method = "PUT",
url = updateUrl,
accessToken = accessToken,
body = org.json.JSONObject()
.put("id", draftId)
.put("title", finalTitle)
.put("content", finalHtml)
)
val updatedContent = updated.optString("content")
check(updatedContent.contains("book-review")) { "記事本文を確認できません。" }
check(updatedContent.contains(imageUrl)) { "写真URLを確認できません。" }
これは今回の実装に合わせた簡易な更新結果の確認である。HTML全体の同一性や、画像が実際に表示できることまで検証するものではない。最終的な内容と表示はBloggerの管理画面で確認する。
全体をつなぐ処理は、この順番になる
細かいHTTP処理をクラスへ分けると、画面側から呼ぶ1回分の処理は次のようになる。これはMainActivity内の実際の抜粋で、設定ストアやAPIクライアントはActivityのメンバーとして用意している。
private suspend fun processPendingDraftOnce(
accessToken: String,
pendingPost: BloggerPendingPostStore.PendingPost
): BloggerApiClient.PreparedDraft? {
val settings = bloggerSettingsStore.load()
val blog = bloggerApiClient.findBlogByConfiguredUrl(
accessToken = accessToken,
configuredBlogUrl = settings.blogUrl
)
val draft = bloggerApiClient.findDraftByUploadToken(
accessToken = accessToken,
blogId = blog.id,
uploadToken = pendingPost.uploadToken
) ?: return null
val imageUrl = bloggerApiClient.extractBloggerImageUrl(draft.content)
?: throw IllegalStateException(
"下書きは見つかりましたが、Bloggerの画像URLを取得できませんでした。添付画像が本文に表示されているか確認してください。"
)
val finalHtml = BloggerEmailPostComposer.insertCoverImageUrl(
pendingPost.htmlTemplate,
imageUrl
)
return bloggerApiClient.replaceDraftWithArticle(
accessToken = accessToken,
blogId = blog.id,
draft = draft,
finalTitle = pendingPost.finalTitle,
finalHtml = finalHtml,
imageUrl = imageUrl
)
}
下書きがなければnullを返す。見つかったのに画像URLが取れなければエラー。本文更新まで確認できればPreparedDraftを返す。この戻り値によって、待つのか、問題を表示するのか、完成として記録するのかを画面側で分ける。
途中で止めても、何を待っていたか分かるようにする
通信が絡むと、毎回一度で最後まで進めるとは限らない。処理待ちの情報を端末へ保存し、中断や再開の操作も加えた。画面を戻ったあとでも、残っている処理を確認できるようにしている。
この区別は、後で追加した履歴削除にもつながる。端末内の記録を整理する操作が、そのままブログ側の記事削除になると扱いづらいからだ。
自動確認は回数を決め、続きの位置を保存する
今回の自動確認は、送信から10秒、30秒、1分、5分の時点で行う構成にした。これはアプリで決めた確認間隔であり、Bloggerがその時間以内に処理を終えるという意味ではない。
private val BLOGGER_AUTOMATIC_CHECK_OFFSETS_MILLIS =
longArrayOf(10_000L, 30_000L, 60_000L, 300_000L)
待機情報には、識別番号、仮のHTML、最終タイトル、写真URI、処理状態、送信時刻、次の確認位置などを保存する。再開時は送信済みかを確認して、送信または下書き確認の段階へ進む。5分後の確認でも見つからなければ、手動確認を案内する。
また、送信済み状態が保存できた場合は再送を避けられるが、通信が途切れて送信結果が分からなくなった場合まで、重複を完全に防げる仕組みではない。送信の成功を取り逃した可能性があるときは、送信先の状態も確認する。端末の状態管理と、通信先で実際に起きたことは分けて扱う必要がある。
ゴールは「公開」ではなく「下書きの完成」
今回のアプリは、完成した記事をBloggerの下書きとして用意するところをゴールにした。メール投稿側も下書きとして受け取る設定で使う。アプリ側の成功表示も、Blogger管理画面で内容を確認する案内になっている。
入力するのは自分の感想。Gemmaが文章を整え、アプリがHTMLを組み立て、写真はメール経由でBloggerへ渡す。最後にAPIで同じ下書きを更新する。内部ではいくつかの処理をつないでいるが、手で写真を貼り直したり、文章をコピーして戻ったりする作業は減らせる。
投稿できたら、今度は記録を残したくなった
写真と記事本文がBloggerの下書きとして揃った。次は、その作成内容をアプリ側でも振り返れるようにする。
第5回は、過去履歴とダッシュボード。記事を作って送るだけだったアプリに、読み返す機能と整理する機能を加えていく。
この記事は実際の開発内容をもとに、説明の順序を整理し、登場人物の会話として再構成しています。構成・文章・会話の作成にはOpenAIのChatGPTを使用しました。サンプルコードは実装から抜粋・整理し、個人の設定値はサンプル表記に置き換えています。アプリ内の文章生成には、端末内で実行するGemmaを使用しています。

0 件のコメント:
コメントを投稿