CopyButton

2026年6月13日土曜日

古い iOS アプリを作り直さず近代化する:Realm 実機クラッシュ・OAuth 移行・StoreKit 2【2026年版】

Oyakodon for Mastodon のアプリアイコン(親子のマンモス) Oyakodon for Mastodon を App Store で見る

この記事は、放置していた自作 iOS アプリを Claude Code に近代化してもらった記録です。私はコードを1行も書いていないどころか、Mac にも Claude Code が動く PC にも一切触れていません。指示はすべてスマホから出しただけで、コード修正・調査・ビルド・TestFlight 配信・App Store 申請まで Claude Code が Mac にヘッドレス(SSH 経由)で実行しました。2017年に公開して以来ほぼ放置していた約9年もの(うち7年半は完全放置)の Mastodon ビューアの「何が壊れていて、何をどう直したか」を技術的にまとめます。

この記事でわかること

  • 放置した古い iOS アプリを、自分でコードを書かずに Claude Code へ近代化してもらう進め方
  • その作業分担(私が頼んだこと / Claude Code が私に求めたこと)
  • 結果として実現した技術的な更新(deprecated API 置き換え・OAuth 移行・Realm 実機クラッシュ修正など)

作業分担:私が頼んだこと、Claude Code が私に求めたこと

最初に私が Claude Code に頼んだのは、ひとことで言えば「7年半放置した Oyakodon を、作り直さずに直して、もう一度 App Store に出せる状態にしてほしい」でした。具体的な実装方針は対話しながら詰めましたが、エディタを開いてコードを書いたのは私ではなく Claude Code です。

Claude Code 側がやったこと(Mac にヘッドレスで):

  • Swift コードの修正そのもの(後述の Before / After はすべて Claude Code の変更)
  • クラッシュログ(.ips)を atos でシンボル解決して原因箇所を特定
  • curl で Mastodon API のレスポンスを叩いて挙動を切り分け
  • SSH 経由で Mac 上の xcodebuild archivealtool を回し、TestFlight へアップロード
  • 提出ビルドのバージョン番号(CFBundleVersion)の更新と、App Store Connect への審査提出(API 経由)。App Store の初期設定(アプリ枠・契約・基本情報)は旧アプリ時代に済んでいたので、私が App Store Connect に新規ログインする必要はなく、申請まで Claude Code が完結させた

私がやったこと(人間にしかできない部分だけ):

  • 「作り直さず近代化する」という方針を決め、スマホから指示を出すこと
  • TestFlight に上がったビルドを自分の iPhone で触り、挙動の異常をスクリーンショットで報告すること。後述の「起動直後に落ちる」「画面下端がズレる」はこの実機確認で見つかった。とくに WebView の位置ズレのように言葉で正確に説明しづらい不具合も、私はスクショを1枚送るだけで済み、それを読んで原因を切り分けたのは Claude Code だった

つまり私は Mac にも、Claude Code が動いている PC にも一度も触れていません。コードも見ていないし、App Store Connect にもログインしていません。私が物理的に関わったのは、手元の iPhone に届いた TestFlight ビルドを触って異常を伝えることだけ。Claude Code の役割は「調査・コード修正・ビルド・配信・申請」、私の役割は「方針決め」と「実機で触って異常を伝える目と手」という分担でした。

背景:何が「腐って」いたのか

題材は Oyakodon という個人開発の iOS アプリです。複数の Mastodon インスタンスを並べて管理できるビューアで、初リリースは2017年4月。つまりアプリ自体は約9年もので、2018年10月の更新を最後に約7年半は完全に放置していました。今回近代化したビルドは App Store(Oyakodon for Mastodon)で配信中です。久しぶりに開いたらビルドすら通らない状態で、原因は1つではありませんでした。

  • iOS SDK 側UILocalNotificationsetMinimumBackgroundFetchIntervalUIImageJPEGRepresentation といった deprecated(非推奨)API が積み重なり、value(forKey: "statusBar") のような private API はそもそもコンパイルを通さなくなっていた
  • Mastodon 側:Web UI の URL ルート変化(/web/timelines/home/home など)、API のバージョンアップ(v1 → v2)、ID の型変化
  • 依存ライブラリ:Realm(モバイル向けデータベース)のスキーマ変化、Firebase API 改訂、レビュー誘導ライブラリ Appirater のメンテ終了
  • 外部サービスの消滅:インスタンス一覧を返していた API(instances.mastodon.xyz)の廃止
  • 認証フロー:password grant(ユーザー名とパスワードを直接送る方式)が廃止され、Authorization Code flow への移行が事実上強制された

つまり「SDK・サーバー・ライブラリ・外部サービス・認証」の5方向から同時に腐食していたわけです。1個直すと次が崩れる、という典型的な放置プロジェクトの状態でした。

方針:作り直さず in-place で近代化する

最初に決めたのは「全面書き直しをしない」ことでした。Oyakodon の差別化機能は、N 個のインスタンスへ同時投稿するマルチポストと、インスタンスをまたいだ boost(ブースト=リポスト)です。この核心部分は既存の Swift コードでちゃんと動いていました。動いているものを捨ててゼロから書き直すと、その「動く」を再現するコストのほうが高くつきます。

そこで、アプリの骨格はそのままに、表面の腐食(deprecated API・廃止された認証・古い型)だけを順番に剥がしていく in-place 近代化を選びました。作業は Phase 0〜8 に分割し、各 Phase は「ビルドが green(成功)になる」ことを区切りにしています。

Phase 別の対応一覧(すべて Claude Code が実施)

Phase内容規模感
0iOS 17 ビルド green / CocoaPods → SPM 移行Pods/ 配下 917 ファイル削除、pbxproj を自動変換
1WebView タイムライン正常化・ログイン保持・設定の外部ファイル化URL ルート表 / CSS / JS を外部ファイルに分離
2廃止された instance picker 撤去・ドメイン直接入力に変更instances.mastodon.xyz 廃止への対応
3Status / Notification / Attachment の ID を Int → String 化比較ヘルパー実装・ユニットテスト16件
4OAuth を Authorization Code フローへOAuth 用 WebView 画面を新設・URL スキーム登録
5/api/v2/media + /api/v2/search 対応・旧 Twitter 連携の死コード削除メディア処理完了待ちのポーリング実装
6BGTaskScheduler + UserNotifications へ古い background fetch 廃止対応、iOS 10 ガード全削除
7Share Extension の型システム移行MobileCoreServices → UniformTypeIdentifiers、139 行削除
8StoreKit 2 への IAP(アプリ内課金)移行250 行 → 79 行に縮小

規模としては最終的に 949 ファイル変更・2,717 行追加・225,731 行削除。削除行が極端に多いのは、Phase 0 で CocoaPods(依存管理ツール)をやめて、コミット済みだった大量のライブラリバイナリを SPM(Swift Package Manager、Apple 純正の依存管理)に置き換えたためです。

技術的に何が変わったか:Before / After

ここからは Claude Code が入れた変更のうち、特徴的なものを Before / After で見ていきます。

1. WebView のログインを、コードが毎起動で壊していた

放置前のコードは、起動のたびにキャッシュをクリアする処理で、ログイン状態を保持する領域(localStorage と IndexedDB)まで巻き込んで消していました。「キャッシュをきれいにする善意」のつもりが、結果として毎起動でログアウトさせていたわけです。

// BEFORE: 毎起動でキャッシュ+ログインデータを全消去
func removeCache() {
    URLCache.shared.removeAllCachedResponses()
    WKWebsiteDataStore.default().removeData(
        ofTypes: [
            WKWebsiteDataTypeDiskCache,
            WKWebsiteDataTypeOfflineWebApplicationCache,
            WKWebsiteDataTypeSessionStorage,
            WKWebsiteDataTypeLocalStorage,       // ← ログイン状態を保持する領域
            WKWebsiteDataTypeIndexedDBDatabases  // ← 同上
        ], modifiedSince: Date(timeIntervalSince1970: 0), completionHandler: {})
    // Library/Caches 以下も全削除...
}
// AFTER: URLCache だけクリア。WKWebsiteDataStore には触らない
func removeCache() {
    URLCache.shared.removeAllCachedResponses()
}

修正は「触らない」だけ。WKWebView(iOS の Web 表示部品)のセッションは WKWebsiteDataStore が握っているので、ここを消さなければログインは保持されます。

2. Mastodon の ID は Int ではなく String で扱う

古いコードは Status(投稿)の ID を Int として保持し、NSString 経由で数値化していました。Mastodon の ID は時間とともに桁が増え続けるため、Int で扱うとオーバーフローの危険があります。素直に文字列のまま扱うのが正解でした。

// BEFORE: NSString 経由でパース(巨大 ID はオーバーフローの恐れ)
public class Status { public var id: Int! }

if let statusId = (item["id"] as? NSString)?.integerValue {
    status.id = statusId
}
let body = ["since_id": "\(sinceId)"]  // 毎回文字列に変換
// AFTER: JSON から String として直接取り出す
public class Status { public var id: String! }

if let statusId = item["id"] as? String {
    status.id = statusId
}
if !sinceId.isEmpty { body["since_id"] = sinceId }  // 空なら送らない

ID を String 化すると大小比較が文字列比較になってしまうので、「桁数が多いほうが新しい、桁が同じなら辞書順」という比較ヘルパーを実装し、ユニットテストを16件付けて担保しています。

3. OAuth:password grant から Authorization Code flow へ

Mastodon 側で password grant(ユーザー名とパスワードを直接トークンと交換する方式)が廃止されたため、ブラウザ経由で認可コードを受け取る Authorization Code flow へ移行が必要でした。

// BEFORE: password grant(廃止済み)
static func fetchAccessToken(addr: String, clientId: String, clientSecret: String,
                            username: String, password: String, ...) {
    let body = [
        "grant_type": "password",  // ← 廃止されたフロー
        "username":   username,
        "password":   password,
        ...
    ]
}
// AFTER: Authorization Code flow(WebView で得たコードのみ送る)
static func fetchAccessToken(addr: String, clientId: String, clientSecret: String,
                            code: String, ...) {
    let body = [
        "grant_type":   "authorization_code",
        "redirect_uri": "oyakodon://oauth",
        "code":         code,
        ...
    ]
}

設計の工夫:Apple 推奨の ASWebAuthenticationSession(OS が用意する認証専用ブラウザ)ではなく、アプリ既存の WKWebView の中で OAuth を完結させています。理由は UX です。ASWebAuthenticationSession はタイムライン表示に使っている WKWebView とは独立したブラウザセッションを使うため、「タイムライン表示用に1回 + OAuth 用にもう1回」と二重ログインを強いてしまいます。既存 WebView の navigation delegate(ページ遷移を監視するフック)で oyakodon://oauth?code=... への遷移を捕まえれば、同一セッションのまま1回のログインで済みます。

4. StoreKit 1 から StoreKit 2 へ(250行 → 79行)

IAP(In-App Purchase=アプリ内課金)は、Observer パターン+デリゲートで状態を手管理する StoreKit 1 の実装でした。StoreKit 2 の async/await ベースに置き換えると、状態フラグやエラーカウントの管理が丸ごと消えて、250行が79行になりました。

// BEFORE: Observer パターン、シングルトン、デリゲート(約250行)
class PurchaseManager: NSObject, SKPaymentTransactionObserver {
    var delegate: PurchaseManagerDelegate?
    func startWithProduct(_ product: SKProduct) {
        if SKPaymentQueue.canMakePayments() == false { ... }
        // フラグ管理、エラーカウント...
        SKPaymentQueue.default().add(payment)  // 結果は Observer へ委ねる
    }
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions: ...) {
        // switch で .purchased / .failed / .restored を振り分け
    }
}
// AFTER: async/await、@MainActor(約79行)
@MainActor final class IAPManager {
    static let shared = IAPManager()

    func purchase(productID: String) async throws -> Bool {
        let products = try await Product.products(for: [productID])
        guard let product = products.first else { return false }
        let result = try await product.purchase()
        switch result {
        case .success(let v):                 return await handleVerification(v)
        case .userCancelled, .pending:        return false
        @unknown default:                     return false
        }
    }

    func restore() async throws {
        try await AppStore.sync()
    }
}

他にも、バックグラウンド通知を古い performFetchWithCompletionHandler から BGTaskScheduler(iOS 13以降のバックグラウンドタスク API)へ、Share Extension を MobileCoreServices の C 定数から UniformTypeIdentifiersUTType へ移行しています。どれも「古い API を新しい API に1対1で置き換える」作業で、最低対応 OS を iOS 17 に引き上げたことで if #available(iOS 10.0, *) のような分岐を全削除できました。

TestFlight で実機に入れて初めて出たバグ3つ

ここが今回いちばん伝えたい部分です。ビルドが通り、シミュレータで完璧に動いても、TestFlight 配信ビルドを実機(iPhone)に入れた瞬間に落ちる・ズレるバグが3つ出ました。私が実機で触って異常を報告し、Claude Code が .ips クラッシュログを atos でシンボル解決して原因を特定する、という流れで潰しています。シミュレータだけ見ていたらリリース後にユーザーの端末で初めて発覚していたものばかりです。

(1) Realm が実機起動で即クラッシュ(埋め込み漏れ)

SPM で追加した RealmSwift は type: .dynamic、つまり常に動的フレームワーク(実行時に読み込まれる形式)です。ところが Phase 0 で pbxproj(Xcode のプロジェクト設定ファイル)を自動変換した際、フレームワークの「リンク」だけ設定して「埋め込み(Embed Frameworks フェーズ)」を追加していませんでした。

結果、シミュレータでは別経路で埋め込まれるため動くのに、実機では起動直後に dyld: Library not loaded: @rpath/RealmSwift.framework でクラッシュします。

判別の小ワザ:正常な IPA(iOS アプリのパッケージ)は RealmSwift.framework を内包して約12MB になります。これが2.5MB しかなければ埋め込み漏れです。アップロード前に IPA のサイズを見るだけで一発で分かります。

(2) Realm の Configuration を無視する罠

2つ目も Realm です。Realm.Configuration.defaultConfigurationschemaVersionmigrationBlock(スキーマ移行処理)を設定していたのに、起動時にクラッシュしました。

原因は、DB を開くときに Realm(fileURL:) を使っていたことです。この初期化方法は defaultConfiguration無視します。そのため既存 DB のスキーマバージョン(2)と、設定が反映されない新しい既定値(0)がミスマッチを起こしていました。

// NG: defaultConfiguration の schemaVersion / migrationBlock が無視される
let realm = try Realm(fileURL: dbURL)

// OK: Configuration を明示的に渡す
var config = Realm.Configuration.defaultConfiguration
config.fileURL = dbURL
let realm = try Realm(configuration: config)

(3) WebView 下端のズレ(3回直してやっと根治)

3つ目は、画面下端で WebView がツールバーに少しめり込む/隙間が空くズレです。これは2.7.2 build 2・2.7.3 build 1・2.7.3 build 2 と、計3回直しています(毎回 TestFlight に上げて実機で確認しました)。1〜2回目は対症療法で、根本原因は「3者がジオメトリ(座標・サイズ)を奪い合っていたこと」でした。

この「何 pt めり込んでいるか」を言葉で正確に伝えるのは難しいのですが、私がやったのは 実機の画面をスクショして送るだけ。ズレの量も方向も、Claude Code が画像から読み取って原因を絞り込んでいきました。テキストだけのやり取りなら何往復もかかったはずの問題が、画像1枚で前に進んだ場面です。実際に私が送った before / after の実機スクショが以下です。

修正前: WebView 下端がツールバーにめり込み、コンテンツが隠れている実機スクリーンショット Before: 下端がツールバーにめり込んでいる 修正後: WebView の4辺を Auto Layout でピン留めし、下端のズレが解消された実機スクリーンショット After: 4辺をピン留めしてズレが解消
私が実機で撮って Claude Code に送った before / after。この画像から Claude Code がズレ量を読み取った。
  • Storyboard の Auto LayouttranslatesAutoresizingMaskIntoConstraints = false のビューは、レイアウトのたびに制約値へ戻る
  • 手動フレーム計算:メニューバー表示/非表示の処理が、deprecated な statusBarFrame を使い、ボトムセーフエリア(ホームバー領域)34pt を無視していた
  • リサイズ処理:コンテナ高さをその瞬間にスナップショットしていたため、ツールバーの開閉アニメーション完了後の変化に追従できていなかった

この3者がフレームの値を毎回上書きし合うので、どこを直しても別の処理が巻き戻していたわけです。根治は「手動フレーム計算とリサイズ処理を捨て、WebView の4辺を Auto Layout でコンテナにピン留めする」こと。1回ピン留めすれば、以後はレイアウトが自動追従します。

// AFTER: 4辺を Auto Layout でピン留め → 以後は自動追従
let webView = self.webViewController.webView
webView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    webView.topAnchor.constraint(equalTo: webContainerView.topAnchor),
    webView.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor),
    webView.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor),
    webView.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor),
])
// 手動の resizeWebView() は削除

設計判断のメモ

  • 作り直さない:差別化機能(マルチポスト・クロスインスタンス boost)が既存コードで動いていたので、それを維持して表面だけ直すほうが確実だった。
  • CocoaPods → SPM:直接のきっかけはビルド環境の制約だったが、コミット済みだった50MB超のバイナリを一掃できる副次効果が大きかった。
  • 設定ファイルの外部化:Mastodon の Web UI ルートは今後も変わり続ける。URL パス表・CSS・JS を JSON やファイルに切り出しておくと、次に変わってもアプリ本体のビルドなしで追従しやすい。将来 Android 版を書くときの仕様共有も狙っている。
  • App Store 審査リスク:WebView でサイトを表示するだけのアプリは、ガイドライン 4.2(minimum functionality=最低限の機能しかない)で reject されることがある。マルチポスト・OAuth・通知・課金といったネイティブ層が、その防御ラインになっている。

結果として 2.7.2 はリリース済み、2.7.3 を App Store 審査に提出できる状態まで持っていけました。実作業期間は約1週間です。

よくある質問

Q. コードは自分で書いたんですか?

A. いいえ、1行も書いていませんし、コードを見てすらいません。私は「作り直さず近代化する」という方針を決めてスマホから指示しただけで、コード修正・調査・ビルド・TestFlight 配信・App Store 申請まで Claude Code が Mac にヘッドレス(SSH 経由)で実行しました。Mac にも Claude Code が動く PC にも触れていません。私が物理的にやったのは、手元の iPhone に届いた TestFlight ビルドを触って異常を報告することだけ。App Store Connect への申請も Claude Code が API 経由で行っています(App Store の初期設定は旧アプリ時代に完了済みだったため、新規ログインは不要でした)。

Q. シミュレータで動くのに実機で落ちるのはなぜ?

A. 動的フレームワークの埋め込み(Embed Frameworks)有無、コード署名、実機固有のセーフエリアなど、シミュレータが肩代わりしてくれる部分が実機では露出するためです。今回の Realm クラッシュはまさにこれで、リリース前に必ず TestFlight などで実機に配信して起動確認すべきです。

Q. 古いアプリは作り直したほうが速くないですか?

A. 差別化機能がまだ動いているなら、作り直しはおすすめしません。「動く」を再現するコストが高いからです。逆に、コア機能自体が時代遅れ・破綻している場合は書き直しのほうが速いこともあります。判断材料は「壊れているのは中身か、表面か」です。

Q. WebView 内で OAuth を完結させるのは安全ですか?

A. セキュリティ的には ASWebAuthenticationSession のほうが望ましいです。今回は二重ログインを避ける UX 優先で WKWebView 内に閉じましたが、自分が管理する認可サーバーでないアプリでは Apple 推奨方式を検討してください。

※本記事のコードは執筆時点(2026年6月)の Xcode / iOS SDK / 各ライブラリで実際に動かしたものを簡略化して載せています。バージョンが変わるとそのままでは動かない場合があります。うまくいかないときは コメント欄で教えてください。なお Bundle ID など環境固有の値はプレースホルダーに置き換えています。

まとめ

約9年もの(うち7年半は放置)の iOS アプリでも、壊れているのが「表面」なら作り直さずに直せます。今回は私自身はコードを書かず・見ず、Mac にも触れず、方針出しと自分の iPhone での実機確認に徹し、コード修正からビルド・TestFlight 配信・App Store 申請までを Claude Code に任せました。山場は、ビルドが通っても実機でだけ落ちる Realm まわりと WebView のジオメトリ。シミュレータを信じすぎず、TestFlight で実機に配信して確かめるのが近道でした。

この記事が役に立ったら X(Twitter)でシェアしてもらえると喜びます。

関連記事

参考

※この記事は Claude Code を使った執筆フローを試しています。

2026年5月31日日曜日

WiFi ルーター買い替え記録:接続台数約30台でも安定するモデルの選び方【WiFi 7 選び方】

マンション1フロア・接続台数約30台の家庭で WiFi ルーターの買い替えを検討した記録です。月に数回ハングアップする6年選手を卒業するにあたって、WiFi 世代の選び方・カタログ速度の落とし穴・最終2択の比較まで全部書きます。同じように「接続台数が増えてきてルーターが不安定」と感じている方の参考になれば。

この記事でわかること

  • 接続台数が多い家庭でルーターがハングアップする本当の原因
  • WiFi 5 / 6 / 6E / 7 の違いと「どの世代を選ぶべきか」の判断基準
  • カタログの「〇〇 Mbps」が1台あたりの速度ではない理由
  • Buffalo WXR-11000XE12 と ASUS TUF Gaming BE9400 の実用的な比較

月に数回のハングアップに限界がきた

使っていたのは TP-Link Archer A10(2018年発売)。購入からおよそ6年経ちます。最近になって月に数回、突然全く通信できなくなるようになりました。

しかも始末が悪いのが、ハング中は管理画面にもアクセスできないこと。ブラウザで 192.168.x.x を開いても無反応。スマホからも当然つながらない。解決策は電源ケーブルを物理的に抜いて強制再起動するだけ。2024年にファームウェアの更新も止まり、今後改善される見込みはありません。

ファームウェア更新が止まった時点で改善の見込みがなくなり、いよいよ買い替えを決断しました。

知らない間に接続台数が約30台になっていた

買い替えを考えてまず試みたのが、接続台数の棚卸しです。数えてみると——

  • スマホ・PC・タブレット:家族4人分。自分は仕事用・プライベート用・開発用と複数台持ち。子供も学校用 PC・個人 PC・スマホをそれぞれ持っており、この時点でかなりの数になる
  • 開発用端末:Claude Code 用 PC、GPU 処理用 PC、Mac、Raspberry Pi 複数台、アプリテスト用 iPhone・Android
  • 家電・IoT:テレビ、レコーダー、AVアンプ、体重計、スマートスピーカー、SwitchBot の電球・温度計・亀のヒーター……

合計すると約30台。子供が大きくなってタブレットやゲーム機が増えたこともあり、気づいたらこの数字になっていました。

Archer A10 はなぜ今まで持ったのか

Archer A10 の公称接続台数は 48台。台数だけ見れば余裕のはずでした。

ではなぜハングアップが起きるのか。推定原因は台数ではなく「処理能力の枯渇」です。

  • 2018年当時のエントリー帯 CPU/RAM が、常時30台近くの接続を安定して捌ける設計になっていない
  • FW 更新停止でメモリリーク等が放置されたまま蓄積している
  • 月複数回のハングアップは「コネクション管理テーブルが消費し尽くされる」典型パターン

つまり台数の問題というより、古い世代のハードウェアが現代の使われ方に追いついていないのが本質です。

WiFi 世代を整理する

買い替えにあたって、まず世代の違いを整理しました。ざっくりまとめると以下の通りです。

世代 規格 周波数帯 多台数への対応
WiFi 5 802.11ac 2.4 + 5 GHz △ 多台数は苦手
WiFi 6 802.11ax 2.4 + 5 GHz ◎ OFDMA で大幅改善
WiFi 6E 802.11ax 2.4 + 5 + 6 GHz ◎ 6GHz 帯で干渉フリー(壁に弱い点に注意)
WiFi 7 802.11be 2.4 + 5 + 6 GHz ◎ MLO(複数帯域同時使用)が目玉

わが家の環境で実際に効く差は WiFi 5 → WiFi 6 の変化です。WiFi 6 で導入された OFDMA という技術が多台数の同時通信を大きく改善します。WiFi 6E はさらに 6GHz 帯を追加し干渉を減らせますが、壁に弱いため設置場所の工夫が必要です。

カタログの「〇〇 Mbps」は1台あたりの速度ではない

ルーターを比べると「AXE11000」「11,000 Mbps」といった数字が目に飛び込んできます。これは全帯域の理論値合計で、1台のデバイスが体験できる速度とは全く別物です。

多台数環境では「ピーク速度」より「全体キャパシティ」で見るのが正しい。Archer A10(約2,600 Mbps)と今回の買い替え候補(約9,000〜11,000 Mbps)では、処理余裕の桁が違います。スペックシートの数字だけで比べる落とし穴にはまらないよう注意が必要です。

最終2択まで絞った過程

条件を整理すると、自然と候補は絞られていきました。

  • WiFi 6 以上(OFDMA 必須)
  • 多台数接続に余裕のあるキャパシティ
  • 予算4万円以内
  • EasyMesh / AiMesh 対応(将来の拡張に備えて)

最終的に残ったのがこの2台です。

機種 WiFi 世代 総キャパシティ メッシュ 発売時期 公称台数 Amazon 実勢価格 FW サポート
ASUS TUF Gaming BE9400(選択) WiFi 7 約9,400 Mbps AiMesh(対応機種多数) 2025年12月 56台 約30,000円
Buffalo WXR-11000XE12 WiFi 6E 約10,800 Mbps EasyMesh(対応機種限定) 2023年4月 60台 約40,000円

TUF Gaming BE9400 を選んだ理由

結論から言うと、ASUS TUF Gaming BE9400 に決めました。

決め手は3点です。

① WiFi 7 + AiMesh の組み合わせが今後も使いやすい
WiFi 7 は MLO(複数帯域の同時使用)が目玉ですが、今の端末では恩恵を受けるには端末更新が必要です。しかし AiMesh の生態系は ASUS の幅広いラインアップに対応しており、後からサテライット追加の選択肢が広い。EasyMesh はオープン規格とはいえ実態は対応機種が絞られます。

② 発売時期の差がサポート期間に直結する
WXR-11000XE12(2023年4月)と TUF BE9400(2025年12月)では発売に2年半の差があります。前のルーターで FW 更新が止まって痛い目を見た経験から、サポート残存期間はかなり重視しました。

③ 1万円の価格差
Amazon 実勢で WXR-11000XE12 は約40,000円、TUF BE9400 は約30,000円です。WXR の 4×4 MIMO・10G WAN という優位点は魅力ですが、宅内が全室 WiFi 運用で現有端末が WiFi 5/6 主体という環境では過剰スペック。その差を1万円で埋めるには至りませんでした。

選外になった機種

今回の比較で検討したすべての機種と除外理由をまとめます。

機種 WiFi 世代 総キャパシティ メッシュ 参考価格 除外理由
ASUS RT-BE92U WiFi 7 約9,700 Mbps AiMesh 約37,600円 TUF BE9400 と同世代・同機能系統なのに約8,000円高い。WAN 10G は現環境では過剰
ASUS RT-AX86U Pro/J WiFi 6 約5,700 Mbps AiMesh 約38,700円 WiFi 7 候補と価格が近いのに1世代古い。選ぶ理由がなくなった
TP-Link Deco XE75 Pro WiFi 6E 約5,400 Mbps OneMesh 約15,800円 価格は魅力だが総キャパシティが他候補の半分以下。約30台の多台数環境には余裕が少ない
TP-Link Archer A10(現行) WiFi 5 約2,500 Mbps なし FW 更新停止・処理限界。今回の買い替え対象

運用方針:まず1台でスタート

設置環境の話をすると、マンション1フロア4部屋で、中央のクローゼットに LAN コネクタを壁面埋め込み済みです。これのおかげでルーターを室内のほぼ中央に置けるのが強みです。

弱点は仕事部屋が一番端にあること。有線 LAN は引いていないため、電波が若干弱い状況です。まずは TUF Gaming BE9400 1台で運用を開始し、電波が足りない場合は AiMesh 対応機をサテライットとして仕事部屋の近くに追加する予定です(ワイヤレスバックホールで対応可能)。

ASUS TUF Gaming BE9400 のパッケージ外観
パッケージ。ロゴもイラストも何もない、シンプルの極み。
開封した ASUS TUF Gaming BE9400 本体、6本のアンテナ
開封するとシンプルな外箱とは対照的に、6本アンテナのカニが登場。
ASUS TUF Gaming BE9400 背面ポート、Gaming port の表示
背面ポート。「Gaming port」と書かれた LAN ポートがあり、ここに繋いだデバイスはパケットが優先的に流れる仕様。

よくある質問

Q. WiFi 7 は今買うべきですか?

A. 値段次第では選択肢に入ります。今回は WiFi 6E の上位機種より安い WiFi 7 ルーターが見つかったため選びましたが、主目的は MLO ではなく、発売の新しさ・AiMesh 対応・価格です。端末が WiFi 7 に揃うまでは WiFi 7 機能そのものの恩恵は限定的です。

Q. WiFi 6E の 6GHz 帯は使ったほうがいいですか?

A. 2.4GHz・5GHz 帯と比べて干渉が少ない反面、壁や床で大きく減衰します。同じ部屋・隣の部屋では威力を発揮しますが、端から端の通信には 5GHz 帯を使うほうが安定する場合があります。端末側の自動バンドステアリングに任せるのが現実的です。

Q. 公称接続台数は信用できますか?

A. 「接続できる台数の上限」と「安定して通信を捌ける台数」は別物です。公称値はアソシエーションの上限であり、常時通信する台数が増えると CPU/RAM の処理限界が先に来ます。特に古い世代のエントリーモデルは、台数が増えると早く限界を迎えます。

Q. メッシュ追加のタイミングはどう判断しますか?

A. 仕事部屋など特定の場所でだけ速度低下・接続切れが続く場合が追加の目安です。まず1台で運用してみて、問題があった場所に限定して中継機を置くのが無駄のない方法です。

※ 本記事の価格・スペックは執筆時点(2026年5月)の情報です。購入前に最新情報をご確認ください。

まとめ

接続台数が増えてルーターが不安定になってきたら、単純な台数スペックより「世代のアップグレードによる処理能力の底上げ」を優先して選ぶのがポイントです。カタログの合算 Mbps ではなく、OFDMA 対応の有無と世代差が多台数環境では効いてきます。

WiFi 7 は端末側も揃わないと目玉機能は使えませんが、価格が WiFi 6E 上位機と逆転してきたケースでは検討する価値があります。発売時期とサポート期間も重要な選択軸です。

この記事が参考になったら X(Twitter)でシェアしてもらえると喜びます。

今回購入・比較検討したルーター

  • ASUS TUF Gaming BE9400 — WiFi 7 / AiMesh 対応 / 2025年12月発売。約30,000円で最新世代を選ぶなら。
  • Buffalo WXR-11000XE12 — WiFi 6E / 4×4×3 = 12ストリーム / 10Gbps 有線ポート。多台数に特化した上位機。

このブログを書いた人のアプリ

本ブログの著者が作った iOS 読書管理アプリ わたしのほんやさん を App Store で公開しています。本棚をシンプルに管理したい方はぜひ。

App Store で見る →

関連記事

※ この記事は Claude Code を使った自動更新を試しています。

2026年5月27日水曜日

HyperFrames を Linux サーバーで動かした構築記録【GPU 不要・日本語フォント豆腐問題の解決まで】

Linux サーバーで動画をプログラマブルに生成したいエンジニア向けです。オープンソースの動画レンダリングエンジン HyperFrames を使い、HTML/CSS/GSAP で書いたアニメーションを headless Chrome + FFmpeg 経由で MP4 に変換する環境を、sudo 権限なし(一般ユーザー)の Ubuntu サーバーに構築した記録を全部書きます。最大の詰まりポイントだった日本語フォント豆腐問題(テキストが□□□になる)の解決策まで含めています。

この記事でできること

  • HTML/CSS/GSAP で書いたアニメーションを MP4 動画に自動変換できる
  • GPU なし・Docker なしで Linux サーバーに動画生成環境を構築できる
  • 日本語テキストが豆腐(□□□)になる問題の原因と解決方法がわかる

使ったもの

  • HyperFrames — HTML/CSS → MP4 変換エンジン(OSS、Apache 2.0)
  • nvm — Node.js バージョンマネージャー(sudo なしでインストール可)
  • Node.js 22 — HyperFrames の実行環境(22 以上が必須)
  • FFmpeg — 動画エンコーダー(静的バイナリで sudo なしインストール)
  • Python 3 — 呼び出し元ラッパースクリプト用

背景

HyperFrames を知ったきっかけは X(Twitter)のタイムラインでした。「GPU なし・Docker なしで HTML を動画に変換できる OSS」という投稿が英語圏のエンジニアを中心にかなり広まっていて、試してみようと思いました。

欲しかったのは SSH 経由で他のスクリプトから MP4 動画を自動生成する仕組みです。GPU を占有したくない、Docker を入れたくない、という縛りの中で HyperFrames は選択肢として合理的でした。HTML/CSS という既存の Web 技術でアニメーションを記述でき、CPU レンダリングで動き、一般ユーザー環境に収まる点が決め手でした。

環境: Ubuntu 22.04、sudo 権限なし(一般ユーザー)。

手順

1. Node.js 22+ のインストール(nvm 経由)

HyperFrames は Node.js 22 以上が必須です。多くの Ubuntu 環境では apt で入る Node.js が古いため、nvm でユーザーレベルにインストールします。

$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
$ source ~/.bashrc
$ nvm install 22 && nvm alias default 22
$ node -v
v22.x.x

詰まりポイント: nvm でインストールした node~/.nvm/versions/node/v22.x.x/bin/ に置かれます。SSH 経由のスクリプト実行では ~/.bashrc が読み込まれないため PATH にこのパスが含まれません。SSH 経由で呼び出すスクリプトでは冒頭に以下を追加してください。

export NVM_DIR="$HOME/.nvm"
source "$NVM_DIR/nvm.sh"

2. FFmpeg のインストール(静的バイナリ)

sudo なしで FFmpeg を入れるため、静的ビルド済みバイナリを取得して ~/.local/bin/ に配置します。

$ mkdir -p ~/.local/bin
$ cd /tmp
$ curl -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
    | tar xJ
$ cp ffmpeg-*-static/ffmpeg ~/.local/bin/
$ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
$ source ~/.bashrc
$ ffmpeg -version | head -1

3. HyperFrames のインストール

$ npm install -g hyperframes
$ hyperframes --version

4. Chrome Headless のインストール

HyperFrames はフレームのキャプチャに headless Chrome を使います。以下のコマンドで自動ダウンロードします。

$ hyperframes browser ensure

nodeffmpeg が PATH に通った状態で実行する必要があります。which node && which ffmpeg で両方の場所が表示されることを確認してから実行してください。

5. 日本語フォント問題の解決(最大の詰まりポイント)

HTML テンプレートに font-family: 'Noto Sans JP' を指定して動画を生成すると、日本語テキストが豆腐(□□□)になります。HyperFrames のログには「Fetched 1116 font face(s) for "Noto Sans JP" from Google Fonts」と出るのに、です。

原因

Google Fonts は Noto Sans JP を Unicode の範囲(サブセット)ごとに分割して配信します。HyperFrames がキャッシュしていたのは Latin サブセット(81KB)のみで、日本語グリフを含む CJK サブセット(1 ウェイトあたり 4〜5MB)はダウンロードされていませんでした。

headless Chrome はシステムフォントを直接参照するため、TTF をシステムフォントディレクトリに直接インストールするのが解決策です。

解決手順

以下のように curl で TTF を直接取得します。User-Agent をブラウザに偽装しないと woff2 サブセット版が返るため豆腐が解決しません。

$ mkdir -p ~/.local/share/fonts

# User-Agent をブラウザに偽装して TTF を取得(必須)
$ curl -L -A 'Mozilla/5.0 (X11; Linux x86_64)' \
    'https://fonts.gstatic.com/s/notosansjp/v56/-F6ofjtqLzI2JPCgQBnw7HFowAA.ttf' \
    -o ~/.local/share/fonts/NotoSansJP-Regular.ttf

# フォントキャッシュを更新
$ fc-cache -f ~/.local/share/fonts/

# 確認
$ fc-list | grep "Noto Sans JP"

fc-list の出力に NotoSansJP が表示されれば成功です。URL 中の版番号(v56)は変わる場合があります。最新の URL は Google Fonts の「Download family」から取得した zip を ~/.local/share/fonts/ に展開する方法でも対応できます(こちらの方が URL に依存しないため安定です)。

ビフォー・アフター

フォント修正前と修正後を並べました。

修正前 — 日本語テキストが□□□(豆腐)になっている
修正後 — 日本語テキストが正常に表示される

6. Python ラッパースクリプトの設計(オプション)

他のスクリプトから SSH 経由で呼び出す場合は、HyperFrames を薄くラップした Python スクリプトを用意すると便利です。設計のポイントは以下の 3 点です。

  • --prompt "テキスト" を受け取り、内部で HTML コンポジションを生成して HyperFrames に渡す(呼び出し元を HTML スキーマから隔離)
  • スタイル(fade / slide / motion)を引数で切り替え可能にする
  • fcntl.flock で同時実行を排他制御する(複数プロセスから呼ばれても安全)

呼び出し側は「SSH 1 コマンドで動画ファイルが返ってくる」インターフェースだけ知っていればよく、内部の HTML テンプレートが変わっても呼び出し側を修正する必要がありません。

完成システム

呼び出し元スクリプト(SSH)
▼ --prompt "テキスト" --output video.mp4
Python ラッパー(HTML 生成 + ロック)
HyperFrames(Node.js + Chrome + FFmpeg)
MP4 動画(720p / 30fps)

daemon なし、コマンド実行のみ。SSH 1 コマンドで動画ファイルが返ります。

よくある質問

Q. nvm: command not found になります

A. SSH 経由で実行すると ~/.bashrc が読み込まれないため発生します。スクリプト冒頭に source "$HOME/.nvm/nvm.sh" を追加してください。

Q. hyperframes browser ensure が失敗します

A. nodeffmpeg が PATH に通っているか確認してください。which node && which ffmpeg で両方の場所が表示されれば OK です。

Q. 日本語以外のフォント(中国語・韓国語など)も豆腐になります

A. 同様の原因です。対象フォントの完全版 TTF を ~/.local/share/fonts/ にインストールして fc-cache -f を実行してください。

Q. 動画が真っ黒になります

A. Chrome がフレームを描画する前にキャプチャされている場合があります。コンポジションの delay 設定を増やすか、CSS アニメーションの開始タイミングを見直してください。

Q. Node.js 20 でも動きますか?

A. HyperFrames は Node.js 22 以上が公式要件です。nvm install 22 && nvm use 22 で切り替えて使用してください。

※本記事の手順・コードは執筆時点(2026 年 5 月)で動作確認していますが、ライブラリやハードのバージョンが変わるとそのままでは動かない場合があります。動かない場合は コメント欄でお知らせください。

まとめ

HyperFrames を sudo なし Ubuntu サーバーに構築しました。詰まりポイントの本命は日本語フォント豆腐で、Google Fonts のサブセット配信仕様と headless Chrome のシステムフォント参照の組み合わせが原因でした。curl の User-Agent 偽装で TTF を取得してシステムフォントに置くことで解決できます。

この記事が役に立ったら X(Twitter)でシェアしてもらえると喜びます。

HyperFrames で作ったブログプロモーション動画

せっかくなので、このブログ「ON THE HAND」のプロモーション動画を HyperFrames で作りました。過去記事の画像+フェードトランジション+テキストキャプションを組み合わせた 30 秒の動画です。日本語テキストもフォント修正後は正常に表示されています。

ON THE HAND ブログのプロモーション動画(HyperFrames で生成・30 秒)

このブログを書いた人のアプリ

本ブログの著者が作った iOS 読書管理アプリ わたしのほんやさん を App Store で公開しています。本棚をシンプルに管理したい方はぜひ。

App Store で見る →

関連記事

参考

※この記事は Claude Code を使った自動更新を試しています。

2026年5月15日金曜日

ブログ更新を Claude Code で自動化する仕組み【テーマ選定・画像・週次 cron の試行錯誤を全部書く】

毎週コンスタントにブログ記事を出すために、テーマ選定・本文生成・画像連動・自動投稿まで Claude Code に任せる仕組みを 1 日で組み上げた。「LLM が知らない直近トレンド」をどう拾うか、「2 つの Claude Code セッションを git 経由で連携させる」とどう設計したか、「毎週金曜の cron 自動投稿」をどう安定させるか、3 つの試行錯誤の経緯と最終形を全部書く。Claude Code を使うと、これだけの仕組みが 1 日で動くところまで立ち上がる。

ノートPC、手書きメモ、マイコンボード、コーヒーカップが並ぶ作業デスクの水彩イラスト

この記事でできること

  • ブログのテーマ選定を Claude Code で自動化するアプローチがわかる
  • 画像生成のような重い処理を別の Claude Code セッションと git 連携させる方法がわかる
  • cron で headless 実行するときのハマりどころと回避策がわかる
  • 「Claude Code に任せる範囲」と「人間が判断する範囲」の線引きの考え方がわかる

使ったもの

  • Claude Code — Anthropic の CLI 版エージェント。本記事の主役
  • Blogger(投稿先)+ Blogger API v3
  • GitHub Pages — 画像配信
  • Python — テーマ偵察スクリプトと MCP サーバ
  • WSL2 + systemd cron — 毎週金曜 18:00 起動

最初にやったこと: 既存記事を Claude Code に読ませて文体を学習させる

このブログは 2015 年から書いてきて記事が約 80 本ある。新しい記事を AI に書かせるとき、これまでの文体・構成・カテゴリ傾向と揃っていないと 「いつもと別人が書いた感」が出る。検索からたどり着いた読者にも、定期読者にも違和感を与える。

そこで最初にやったのは、既存記事を Claude Code に全部読ませて、文体ガイドとトピック傾向をまとめさせること。具体的にはこうした:

  1. Blogger API(posts.list + posts.get)で全公開記事の HTML をローカルにバックアップ
  2. Claude Code に「あなたが今後この同じ著者として記事を書くために、文体の特徴・よく使う章立て・カテゴリ分布・避けるべき書き方を分析して、再利用できるようにまとめて」と依頼
  3. 出力は memory(Claude Code がプロジェクト固有の知識を持続的に保持する仕組み)に保存

抽出された知見の例:

  • 言い回しの癖: 「最短手順」「全部書く」「詰まりポイント」のような実用記事ボキャブラリ、固有のユーモアの分量感(1〜2 文に収める)
  • 章立てのよく使う形: リード → 使ったもの → 手順 → 詰まりポイント → まとめ → 関連記事
  • 14 のカテゴリ分布: 電子工作 / Raspberry Pi / Arduino / IoT / AI / 3Dプリンタ / 製品レビュー / ルンバ / ガジェット改造 / 音声合成 など
  • 避けるべき書き方: 過剰な絵文字、シリアスすぎて読み手を選ぶ語り口、「〜と思います」の連発
  • 過去 8 年分の記事タイトル全リスト: 「同じテーマを再度書かない」ための重複チェック源

これらが memory に入っていることで、Claude Code は新規記事を書くときに 「過去の自分の延長として」書ける。新規生成記事と過去記事の間の連続性が保たれる。memory はセッションをまたいで残るので、毎回プロンプトに長文の文体ガイドを貼り付ける必要がない。

なぜ自動化したのか

毎週コンスタントに記事を出したい。一方で、毎回ゼロから「最近何が話題か」を調べるのは時間が取れない。

Claude Code に「電子工作系で最近話題のテーマで書いて」と素直に頼むと、LLM の学習時点で知っているテーマから拾ってきてしまう。直近 1 年のトレンドや自分の興味とは微妙にズレる。

解決アプローチは 「直近のトレンド」「自分の興味」「過去記事との重複回避」を構造化した情報として外から流し込むこと。LLM 単体で完結させるのを諦めて、Python スクリプトで事前に下調べし、その結果を Claude Code への入力として渡す。

全体の流れ

毎週金曜 18:00 cron
BlogGen 担当 Claude Code セッション
テーマ偵察 → 記事生成 → 画像連動 → ドラフト投稿
RSS 20サイト + SNS
テーマスコアリング
別マシン Claude Code
画像生成サービス
GitHub Pages
画像配信
Blogger API でドラフト投稿
人間が中身を確認 → 公開ボタン

ハマり 1: テーマ選定 — 「LLM が知らない直近トレンド」をどう拾うか

ここで一番悩んだ。文体は memory で揃えられても、「何について書くか」を決めるのはまた別の問題。

最初の試行: WebSearch だけ → キーワードが偏る

「電子工作系で最近話題のテーマで書いて」と Claude Code に頼んで、WebSearch を数回叩いてもらう運用から始めた。やってみるとキーワードが偏る。「Raspberry Pi」「ESP32」「Home Assistant」のような LLM がよく知っている定番に収束する。直近の新製品(Pico 2 W、XIAO ESP32-S3、SCD41 など)にうまくフォーカスできず、過去にも書いたようなネタが繰り返される。

原因: WebSearch クエリ自体が LLM 知識空間内の単語に依存する。新しい単語は知らないのでクエリに含まれず、結果も新しいトレンドを拾わない、という鶏卵問題。

解決策: 構造化された「直近 1 年のトレンド」を外部から流し込む

国内 10 + 海外 10、計 20 サイトの RSS フィードを Python で定期取得して、直近 1 年分の記事タイトルからキーワードを抽出・スコアリングする research_topics.py を作った。20 サイトの内訳:

国内サイトだけだと国内ネタに偏るので、海外 10 を併用して「日本未紹介の海外先行ネタ」を拾えるようにした。海外で話題になっているのに国内サイトでは未紹介のテーマは「日本語先行紹介」枠として候補入り。

スコアリング式

各キーワードに以下のスコアを付ける:

score = frequency × (1 + cross_source × 0.4) × cross_region_bonus × affiliate_fit × novelty × sns_boost
  • frequency: 直近 1 年でそのキーワードが何記事に出てきたか
  • cross_source: 何サイトで言及されているか(広く話題なら高)
  • cross_region: 国内・海外の両方で言及されていれば +bonus(信頼性が高いトレンド)
  • affiliate_fit: Amazon で購入できる物理製品が紐づくテーマか(マイコン・センサー・部品系は高、純ソフトウェアは低)
  • novelty: 過去記事のタイトル一覧(Blogger API で取得)と照合して、すでに書いたテーマは下げる
  • sns_boost: 後述

SNS シグナル: 「自分も触れたガジェット」を 2 倍ブースト

世間で話題なテーマと、自分が SNS で言及したことがあるテーマは別物。前者だけだと「自分の興味」を反映しないし、自分が試したことがないハードを書くと内容も薄くなる。

そこで XFacebook の公式アーカイブ(個人データダウンロードで取得できる ZIP)を sns_data/ 以下に展開しておき、過去 1 年の自分の投稿テキストからキーワードを抽出。外部 RSS で言及があり、かつ自分も SNS で触れたキーワードはスコアを 2 倍ブースト

結果として上位に来るのは、「世間で盛り上がっている × 自分も触ったことがある」テーマ。経験ベースで深く書ける。

SNS アーカイブは月〜四半期に 1 回手動で再取得して上書きする運用。API スクレイピングではなく公式アーカイブを使うことで、X / Facebook の API 仕様変更で壊れないし、画像も同梱されるので記事の写真素材にも流用できる。

NG ワードフィルタ: 「ガジェットのハック」と「ライフハック」を区別する

もう一つのハマり: 「ライフハック」「生活ハック」のような汎用ワードが上位に紛れ込む。スマホアプリの組合せや時短テクなど、本ブログの読者が期待していない汎用ネタ。

これらは NG ワードとして明示的にフィルタした。「電子工作系の改造ネタ」と「単なる便利ライフハック」を区別する基準を文字列ベースで明示する必要があった。人間にとっては自明でも LLM の判断は揺れるので、ルールを文字列で固定する方が安定する。

22 種類の記事タイプ分類でローテーション

もう一段: 22 種類の「記事タイプ」分類を作った。マイコン+センサー型ばかり連発しないようにするため。例(一部):

説明
Aマイコン+センサー(Pico/ESP32 + I2C 温湿度・CO2 → MQTT 等)
B既製ガジェット改造・API ハック(SwitchBot / Echo / AirTag 等)
C3D プリンター + 機構自作
DAI × エッジ(Ollama on Pi、ローカル音声・画像認識)
Mレトロ・エミュレーション
N時計・タイムキーパー(Word clock、Nixie 時計、E-ink カレンダー)
POS 移行・デュアルブート・サーバ化(Asahi Linux、Proxmox 等)
T開発環境・IDE・ワークフロー(Node-RED、WSL2、Claude Code 等。本記事の型)
他 14 型(ロボット、無線・通信、電源、オーディオ、ストレージ、レビュー、ウェアラブル、自作 PC など)

正規表現でキーワードと型を対応付けた分類器を組んでおき、直近 5 記事でどの型が多いかresearch_topics.py 出力に含める。Claude Code は CLAUDE.md の指示で「直近 5 件のうち 2 件以上が同じ型なら、別の型を選ぶ」運用になっている。マイコン+センサーの記事を 2 週連続で出さないための仕組み。

最終的な出力フォーマット

スクリプトは毎回こんな感じの候補リストを出す(簡略例):

# 直近 5 記事の型分布: A×2, B×3 → A,B 以外を優先

# 投稿候補テーマ Top 8

1. ✨🌐💰 XIAO ESP32-S3 で CO2 モニタを ESPHome から Home Assistant に流す【型 A】
   根拠: 国内 4 サイト × 海外 6 サイト言及 / SNS で 3 回触れた / アフィリエイト適性高

2. 🌐💰 ファミコン互換機を Wipeout 移植機にする【型 M レトロ】
   根拠: 海外 5 サイト言及(国内未紹介)/ アフィリエイト適性中

3. 💰 Asahi Linux で Mac mini を Linux サーバ化する【型 P OS 移行】
   ...

(以下省略)

各候補に ✨ SNS シグナル / 🌐 国内外横断 / 💰 アフィリエイト適性のフラグが付き、根拠と該当する記事タイプも併記される。Claude Code はこの中から、直近の型分布と被らない 1〜3 件を選んで深掘り(Step 1-C のハック手法調査)に進む。

「外部から構造化情報を流し込む × LLM が選ぶ」という分業にしたことで、テーマ選定がそれなりに動くようになった。

ハマり 2: 画像をどう用意するか

記事のカバー画像(OG image)は SNS シェア時のサムネイルに使われるので、ないと CTR が大きく落ちる。LLM 単体では画像生成できないので、外部サービスとの連携が必要。

選択肢を試した: Blogger admin で手動アップロード(API 自動化と相性悪い)、xAI Grok API(月額課金)、ローカル GPU で SDXL を回す(採用)。最終的に GTX 1660 SUPER 搭載 PC に ComfyUI を立ててローカル生成、生成画像は GitHub Pages 経由で配信する構成に落ち着いた。

ローカル GPU 画像生成サーバ自体の構築過程と、参照画像から実機の見た目に寄せたり水彩イラストに変換したりする話は 前記事「ブログ用 AI 画像をローカル GPU で生成する」に詳しく書いた。本記事ではその先 — 「画像生成サービスを進化させたくなったとき、Claude Code をどう使ったか」に絞る。

ハマり 3: 2 つの Claude Code セッションを git で連携させる

これが一番面白かった。記事を書く側(BlogGen)と画像を生成する側(ImgGen)はそれぞれ 別のリポ・別のマシン・別の責務。それぞれに Claude Code セッションが付く。

でも連動はしたい。たとえば「ImgGen 側に --style illustration オプションを増やしたい」と思ったとき、その仕様は呼出側の BlogGen も知っている必要がある。

採った方法: git の仕様書ファイルを「両者が見る contract」にする

BlogGen 担当
記事生成・呼出側スクリプト・仕様書ホスト
ImgGen 担当
別マシン・画像生成サービス本体・実装側
┃ ┃
git 上の仕様書(docs/<feature>_spec.md
両者が見る contract

具体的な連携サイクル:

  1. 段階 1(仕様起票): BlogGen 側のセッションが新機能の引数名・期待動作・ワークフロー JSON 仕様を spec に書いて push
  2. 段階 2(実装): ImgGen 側のセッション(GPU PC 上)がリポを pull、実装、push back
  3. 段階 3(検証): BlogGen 側が pull、実テスト、生成画像の品質確認
  4. 段階 4(フィードバック): BlogGen 側が実測値(推奨パラメータ)を spec に追記、ステータスを ✅ に更新

良かった点:

  • 進行が止まらない: ImgGen 側の実装を待つ間、BlogGen 側は他の検証や記事生成に進められる
  • API ズレが起きにくい: spec が contract として機能するので「引数名が微妙にズレている」が起きにくい
  • 各セッションが自分のリポだけ意識すればよい: 認知負荷が低い
  • 記録が git history に残る: どのフェーズが・いつ・どちらのセッションから動いたか追跡可能

ハマりポイント:

  • 同時編集の上書き事故: 一度、BlogGen 側が spec を push する直前に ImgGen 側が別の修正を push していたのに気づかず、古い SHA で PUT して上書きしかけた → 直後の commit で復元
  • 教訓: gh CLI で spec を更新するときは PUT 直前に必ず gh api repos/.../contents/foo --jq .sha最新 SHA を取り直す
  • 引数名のリネーム時は後方互換 alias を必ず残す(呼出側が壊れる)

ハマり 4: 毎週金曜 cron + 権限ホワイトリスト

毎週金曜 18:00 JST に cron で Claude Code を --permission-mode acceptEditsheadless 実行。指定したプロンプトを 1 回実行して終了する。

最初の試行: cron 起動時に サイレントタイムアウト。原因は権限。Claude Code は対話的セッションでは「この Bash コマンド実行していい?」と都度聞くが、headless 実行ではユーザー入力ができないため、許可されない操作で詰まる。

解決策: .claude/settings.local.jsonpermissions.allow に必要な Bash パターンと WebFetch ドメインを事前に許可登録。「ホワイトリスト方式」で必要な操作を一つひとつ追加していく運用。

運用上の注意: 新しいツール呼出が必要になるたびにホワイトリストの追加が要る。cron ログ(logs/weekly_draft_*.log)を週次で眺めて「許可されなくて詰まった操作」を見つけたら settings.local に追記する。「最初に全部読んで漏れなく追加」より「動かして・壊れて・直す」のサイクルのほうが結局速かった。

例: scp の複合パターン Bash(scp take@host:* *):* がパターン途中にある形だと無効になる。Bash(scp take@host:**) のように :* を末尾に置く形に直す、というローカルなハマりも出てくる。Claude Code の /doctor コマンドで警告が出るので気付ける。

ハマり 5: Claude Code に任せる範囲 vs 人間が判断する範囲

「全部 AI に任せる」のは怖い。一方で「全部人間がやる」のは元の問題(時間が取れない)に戻る。線引きが要る。

採った設計:

フェーズ 担当
RSS / SNS からのトレンド抽出スクリプト
候補テーマからの 1 件選定Claude Code
記事 HTML 本文生成Claude Code
薬機法・景表法 NG ワードチェックClaude Code
参照画像の検索 → 取得Claude Code
AI 画像生成別セッション
GitHub Pages への画像 pushClaude Code
Blogger API でドラフト投稿Claude Code
最終的な公開ボタン人間
事実関係・タイトルのチェック人間

キーは 「ドラフトで止める」こと。Blogger API でドラフト投稿はするが、公開ボタンは押さない。人間が中身を見て、文体・事実関係・タイトルを最終チェックしてから公開する。

これにより「変な記事が出ても可逆」になる。Claude Code が試行錯誤するときの心理的安全性が確保できる。実際、テーマ選定スクリプトの初期版が「ライフハック」を量産していた時期や、画像生成パラメータ調整中も、ドラフトで止めていたおかげで安心して試せた。

よくある質問

Q. ドラフト生成に失敗したらどうなりますか?

A. cron が weekly_draft.sh を起動して Claude Code が走るが、途中で詰まったり権限が足りなかったりするとログにエラーが残ってドラフトは作られない、というだけ。次の金曜にまた走るだけなので、致命的な事故にはならない。

Q. なぜ Claude Code を 2 セッションに分けたのですか?1 セッションで両方できないのですか?

A. 物理的に別マシン(一方は GPU 搭載 PC、一方は普段使いの Linux PC)なので、それぞれの環境に張り付いた Claude Code セッションが触れる範囲が違います。別セッションで動かすことで、それぞれ自分のリポだけ意識すればよく、認知負荷が下がります。git の仕様書を contract にするとこのスタイルが成立します。

Q. 全自動公開はやらないのですか?

A. やりません。「人間が最後に見る」前提にすることで、AI による生成を試行錯誤しやすくなる、という設計です。誤情報・薬機法・景表法などのリスクを完全には自動チェックできないため、最後の関門は人間に残します。

Q. テーマ選定スクリプトはどのくらいのペースで再実行されますか?

A. cron 起動時、つまり週 1 回。RSS と SNS アーカイブから直近 1 年分のキーワード分析をその場で計算するので、結果は毎週変わります。SNS アーカイブは月〜四半期に 1 回、手動で再ダウンロードして上書きする運用です。

Q. 立ち上げにどのくらいかかりましたか?

A. 丸 1 日です。RSS 偵察スクリプト・MCP サーバ・Blogger API 連携・cron 設定・CLAUDE.md(NG ワードや文体ガイド含む)まで、CLI で対話しながら Claude Code が書き上げました。普通なら仕様策定・実装・デバッグで数週間かかる規模の仕組みが、対話するだけで一気に動くところまで進みます。

Q. 文体や記事構成は AI とどうやって揃えるのですか?

A. 過去記事を Blogger API でローカルに取得し、Claude Code に文体の特徴・章立て・カテゴリ傾向を分析させて memory に保存しました。memory はセッションをまたいで残るので、新規記事を書くたびに毎回プロンプトに長文の文体ガイドを貼り付ける必要がありません。Claude Code は「過去の自分の延長」として記事を組み立てます。

※本記事の手順は執筆時点(2026 年 5 月)で動作確認していますが、Claude Code・Blogger API・各種 OSS のバージョン更新によりそのままでは動かない場合があります。動かない場合は コメント欄でお知らせください。

まとめ

Claude Code でブログを自動化するときに効いた設計判断は次の 4 つに集約できる:

  1. 過去記事を AI に読ませて memory に文体・カテゴリ傾向を保存: 「過去の自分の延長として書く」連続性を最初に作る
  2. 「LLM 単体で完結」を諦める: 直近トレンド・自分の興味・過去記事一覧を Python で構造化して外から流し込む。LLM の知識だけで書こうとすると定番に収束する
  3. 2 つのセッションを git の仕様書で連携: 別マシン・別リポをまたぐ作業で「進行が止まらない」「API ズレが起きにくい」という効果が大きい。spec を contract にする発想は、Claude Code 以外の文脈でも応用が効く
  4. 公開のゲートは人間が押す: ドラフトで止めることで「失敗が可逆」になり、AI による生成を試行錯誤しやすくなる。完全自動化を目指さないことが結果的に自動化を進める

そして強調したいのは 立ち上げの速さ。RSS 偵察スクリプト・MCP サーバ・Blogger API 連携・cron 設定・CLAUDE.md(NG ワードや文体ガイドを含む)まで、丸 1 日で Claude Code が書き上げた。普通なら数週間かかる仕様策定・実装・デバッグが、CLI 越しに対話するだけで一気に進む。「やりたいことを言葉で説明する → ほぼ即時に動く実装が出る」サイクルが回せるので、設計判断の試行錯誤コストが劇的に下がる。

立ち上げ後は、毎週金曜の夜にドラフトが 1 本上がってきて、土日に確認して公開、というリズムになっている。完全自動化を目指さず「人間が最後に見る」前提にすることで、安心して任せる範囲を少しずつ広げられる。

この記事が役に立ったら X(Twitter)でシェアしてもらえると喜びます。

関連記事

参考

※この記事は Claude Code を使った自動更新を試しています。

2026年5月11日月曜日

GTX 1660 SUPER で SD1.5 ControlNet 環境を構築した話:VRAM ではなく RAM が詰まった

GTX 1660 SUPER(VRAM 6GB)に SD1.5 + ControlNet 環境を構築した記録。詰まったのは VRAM 不足ではなく「RAM が ~26GB に跳ね上がる OOM」「fp16 の全黒 NaN 画像」「math SDPA 強制で 3.2x 遅延」の 3 点だった。さらに後から「全ステップ計測値がスピルによる誤計測だった」という誤算も発覚した。原因から修正まで全部書く。

この記事でできること

  • VRAM 6GB の GPU で ControlNet が動く SD1.5 構成がわかる
  • GTX 16xx 系(TU116)の fp16 NaN 問題と回避策がわかる
  • sequential_cpu_offload が「VRAM ではなく RAM を食う」仕組みがわかる
  • 最終的な速度: Hyper-SD15 1-step ~5 秒/枚、フル 25-step ~1 分/枚の実測値が得られる

使ったもの

背景:SDXL の ControlNet を 1 時間 13 分で廃止した

もともと別環境(SDXL ベース)で ControlNet を実装しようとしていた。2026-04-30 21:40 に実装が完走したのだが、22:53 には廃止 commit を打っていた。1 時間 13 分。

原因は RAM だった。SDXL で enable_sequential_cpu_offload を使うと VRAM ピークは ~4GB に収まる。しかし、この仕組みではモデル全体が RAM に常駐する。そこに ControlNet(fp32 で ~5GB)を追加すると:

構成 VRAM ピーク RAM 常駐 結果
SDXL fp32 + FaceID ~4GB ~21GB ✅(24GB 環境)
+ ControlNet を追加 ~4GB(変わらず) ~26GB ❌ OOM

VRAM は問題なかった。ControlNet 追加のコストは RAM 側に出る。「VRAM 6GB に収まっているのになぜ OOM?」という不可解さは、sequential_cpu_offload の仕組みを理解しないと解けない。

sequential_cpu_offload の仕組みを整理する

diffusers の enable_sequential_cpu_offload は UNet の ~700 サブモジュールを 1 つずつ VRAM に転送→推論→RAM に戻す、を毎ステップ繰り返す。モデル全体は RAM に常駐し続ける。CPU↔GPU のピンポンが 1 ステップに 700 回あり、Python GIL で 1 コア 100% 張り付く構造だ。

SD1.5 は全体 ~4GB (fp32)。VRAM 6GB に収まるので sequential_cpu_offload が不要になる。これが SD1.5 別環境を立てた動機だった。

詰まった問題 3 つ

① fp16 で全黒 NaN 画像が出る(GTX 16xx 系の既知問題)

5 構成でマイクロベンチを実行した:

構成 step 時間 出力
T1: SD1.5 fp16、CN なし、math SDPA 15.8 s/step 全黒 1224 bytes(NaN)
T3: + ControlNet 1 個、math SDPA 22.6 s/step NaN
T4: + flash + mem-efficient SDPA 7.1 s/step NaN(速くなったが)
fp32 に変更(SDPA デフォルト) 後述 ✅ 正常生成

SDPA の種類を変えても NaN は消えなかった。原因は GTX 1660 SUPER(TU116)が Tensor Core を非搭載であること。attention layer の fp16 softmax で underflow/overflow が起きる。AUTOMATIC1111 が GTX 16xx 向けに --no-half フラグを公式提供しているのと同じ問題だ。fp32 に変えると即解決した。

② math SDPA 強制が 3.2x 遅延の原因だった

T3(22.6 s/step)と T4(7.1 s/step)の差。別環境から流用したコードが原因だった:

# 別環境から引き継いだコード(fp16 NaN 対策として書かれたもの)
torch.backends.cuda.enable_flash_sdp(False)
torch.backends.cuda.enable_mem_efficient_sdp(False)
torch.backends.cuda.enable_math_sdp(True)

このコードを削除して PyTorch のデフォルト自動選択に任せることで 3.2x 改善できる。ただし flash SDPA は fp16/bf16 専用なので、fp32 化すると math kernel に戻る。「強制コードを削除 + fp32 化」が正しい組み合わせだ。

③ 全計測値がスピルによる誤計測だった(後から判明)

環境構築後、vram_peak_mb メタデータを生成画像に出力するようにして計測したところ、fp32 + ControlNet 1 個で VRAM ピークが 6,726 MB に達していた。GTX 1660 SUPER の WSL2 実効上限は ~6,100 MB。

全部スピルしていた。初期の計測値(13.7 s/step / フル 25-step 5分55秒)は全部 WSL2 がホスト RAM にスピルした状態での値だった。

対策は enable_model_cpu_offload(モデル単位、5〜10 回の CPU↔GPU 入れ替え):

構成 --offload none(スピルあり) --offload model(現行 default) 改善率
フル 25-step + 1 CN 584.7s / VRAM 6,726 MB 61.5s / VRAM 5,298 MB 9.5x
Hyper-SD15 4-step + 1 CN 62.9s / VRAM 6,680 MB 11.1s / VRAM 5,146 MB 5.7x
Hyper-SD15 1-step 32.4s / VRAM 6,680 MB 4.9s / VRAM 5,106 MB 6.6x
Multi-CN (2 個) 25-step 884.6s / VRAM 8,124 MB 680.1s / VRAM 6,694 MB 1.3x(まだスピル)

1 CN 構成では VRAM が 5.1〜5.3GB に収まりスピルなし → 9.5x の高速化。Multi-CN(2 個)は --offload model でも 6.7GB でスピルが残るため改善が限定的だ。

SD1.5 の低 step 蒸留:SDXL Lightning に相当するものは?

SDXL で使っていた Lightning は SD1.5 版が存在しない。ByteDance は SDXL 専用にしかリリースしていないからだ。SD1.5 の対応手法:

  • LCM-LoRA--lcm): 4-step。~10 秒/枚
  • Hyper-SD15 CFG-distilled--hyper {1,2,4,8}): 1-step で ~5 秒。negative prompt 無効
  • Hyper-SD15 CFG-preserved--hyper-cfg {8,12}): 8-step で ~4.5 分。negative prompt 有効、過飽和しにくい

ポーズ探索は --hyper 1(5 秒/枚)で大量に回し、人物写真の仕上げは --hyper-cfg 8(negative prompt 有効)が使いやすい。

2 環境の役割分担(確定版)

SD1.5 環境 SDXL 環境
主な用途 ポーズ・構図の探索 最終出力・FaceID portrait
速度(フル 25-step) ~1 分/枚(61.5s 実測) ~16 分/枚
速度(低 step 蒸留) Hyper 1-step ~5 秒 Lightning 8-step ~6-7 分
解像度 512×768 (native) 1280×720 / 832×1216
ControlNet OpenPose / Canny / Depth ✅ ❌(RAM 制約で廃止済み)

推奨ワークフロー: SD1.5 で --hyper 1(5 秒/枚)を使ってポーズ・構図を大量に試し、確定したら SDXL 環境に参照画像ごと渡して最終出力を得る。SD1.5 の OpenPose 骨格画像は SDXL の ControlNet モデルと latent space が違うので直接は使えないが、元の参照画像を IP-Adapter 入力として渡す形で橋渡しできる。

よくある質問

Q. GTX 1660 SUPER で fp16 を使えないか?

A. 現状は fp32 一択です。TU116 は Tensor Core 非搭載で attention layer の fp16 softmax が underflow/overflow します。AUTOMATIC1111--no-halfComfyUI--force-fp32 と同根の既知問題です。RTX 3060 以降(Ampere)なら fp16 が使えて flash SDPA で 1-2 s/step が期待できます。

Q. SDXL に ControlNet を追加する方法はないか?

A. FaceID を使わない構成(txt2img + ControlNet のみ)なら RAM ~19GB で動作可能です。FaceID と ControlNet の同時利用は RAM ~26GB を超えるため SD1.5 に分担するのが現実的な選択です。

Q. Multi-ControlNet(2 個同時)はどれくらい時間がかかる?

A. --offload model でも VRAM 6.7GB でスピルが残るため ~11 分/枚です。1 個の時と比べて改善が限定的なのはスピルが完全には解消しないからです。

※本記事の手順・数値は 2026 年 5 月時点の環境(diffusers 0.38、GTX 1660 SUPER、WSL2 + Ubuntu 22.04)で確認しています。ライブラリや GPU ドライバのバージョンが変わると動作が変わる場合があります。動かない場合はコメント欄でお知らせください。

まとめ

GTX 1660 SUPER(VRAM 6GB, RAM 24GB)に SD1.5 + ControlNet 環境を構築した。3 つの落とし穴(fp16 NaN、math SDPA 強制遅延、VRAM スピルによる誤計測)を経て、最終的に Hyper-SD15 1-step ~5 秒/枚・フル 25-step ~1 分/枚が出る構成になった。SDXL 環境(16 分/枚)との使い分けでポーズ探索を高速化できる。

この記事が役に立ったら X でシェアしてもらえると喜びます。

このブログを書いた人のアプリ

本ブログの著者が作った iOS 読書管理アプリ わたしのほんやさん を App Store で公開しています。本棚をシンプルに管理したい方はぜひ。

App Store で見る →

参考

※この記事は Claude Code を使った自動更新を試しています。

2026年5月10日日曜日

X API を Python で使う【2026年版・無料廃止・認証の罠・tweepy v1.1+v2 使い分け】

この記事は Python で X(旧 Twitter)API に自動投稿を組み込もうとしている開発者向けです。2026年5月時点の X API は無料プランが廃止されており、認証まわりの罠が2つある。実際にハマった体験をもとに、tweepy v1.1 と v2 の使い分けまで手順を全部書く。

この記事でできること

  • Python + tweepy で X に自動投稿(テキスト+画像)できる
  • 2026年の X API 料金体系(無料プラン廃止・従量課金)がわかる
  • 401・402 エラーで詰まったときの対処法がわかる
  • 月のコストをざっくり試算できる

使ったもの

  • tweepy(Python X API ラッパー)
  • Python 3.11+
  • X Developer Portal アカウント(要クレジットカード登録)

X API の現状(2026年5月)

結論から言うと、無料プランは存在しない

以前は Free tier があり「個人ブログのボット程度なら無料でいける」という記事が多かった。が、現在 X Developer Portal を開くと Basic プランへの登録しか選べず、従量課金が走る。

料金体系

  • URL 付きツイート 1 件 = 0.20 USD(URL なしは別体系・安い)
  • 月次 Spend Cap はデフォルトで無制限。設定しないと青天井でチャージされ続けるので危険
  • 上限に達すると次のサイクル開始まで API がブロックされる

Spend Cap を手動で設定しておくことを強く推奨する。設定しないと使いすぎても自動では止まらない。

実際に Spend Cap を設定後、テスト投稿 4 件目で上限に達し以下のエラーが出た:

Your enrolled account has reached its billing cycle spend cap.
API requests will be blocked until the next cycle begins on 2026-06-10.

Spend Cap は Developer Console → Billing → Spend limit から設定・変更できる。上限に達しても即時引き上げ可能。

ハマりポイント 1:401 の罠(初期設定後のトークン再作成)

アプリを最初から「Read and Write」で作成したにもかかわらず、API 呼び出しで 401 が返ってきた。Developer Portal 上はトークンが有効に見えるのに、実際のリクエストが弾かれる。

原因は特定できなかったが、初期設定直後は Access Token & Secret を一度 Regenerate(再作成)すると解消する

対処

  1. Developer Portal → App → Keys and tokens
  2. 「Access Token & Secret」の「Regenerate」をクリック
  3. 新しいトークンをコードに反映

初期セットアップ後に 401 が出たら、まずトークンを再作成してみるのが最短の解決策。X のドキュメントにはこの手順が目立つ形で書かれていないため、詰まりやすい。

ハマりポイント 2:402 の罠(クレジット未入金)

トークン再発行後に再度 API を叩いたら、今度は 402 が返ってきた:

Your enrolled account does not have any credits to fulfill this request.

無料プランがないので、クレジット残高ゼロだとリクエストが弾かれる。Developer Console でクレジットカードを登録して最小額チャージすれば解消する。

実装:tweepy v1.1 と v2 の使い分け

現時点の tweepy で画像付きツイートを投稿するには、2 つの API バージョンを同時に使う必要がある

  • ツイート投稿:tweepy v2 Client(OAuth 2.0)
  • 画像アップロード:tweepy v1.1 API(v2 には media upload が存在しない)
import tweepy

# ---- 画像アップロード(v1.1)----
auth = tweepy.OAuth1UserHandler(
    "{API_KEY}", "{API_SECRET}",
    "{ACCESS_TOKEN}", "{ACCESS_TOKEN_SECRET}"
)
api = tweepy.API(auth)
media = api.media_upload(filename="/path/to/image.png")
media_id = str(media.media_id)

# ---- ツイート投稿(v2)----
client = tweepy.Client(
    consumer_key="{API_KEY}",
    consumer_secret="{API_SECRET}",
    access_token="{ACCESS_TOKEN}",
    access_token_secret="{ACCESS_TOKEN_SECRET}"
)
response = client.create_tweet(
    text="記事タイトル\nhttps://example.com/your-post",
    media_ids=[media_id]
)
print(response.data["id"])

v2 だけで完結させようとすると media_upload が見つからずエラーになる。v1.1 で画像をアップして media_id を取り、それを v2 の create_tweet に渡すのが現時点での正解だ。

コスト試算

URL 付きツイート 0.20 USD/件として:

  • 週 3 投稿 × 4 週 = 月 12 件 → 2.40 USD/月
  • 週 3 投稿 × 5 週 = 月 15 件 → 3.00 USD/月

月 3 USD は「安い」とは言い切れない。X への自動投稿は新しいコンテンツを生み出すわけでも、投稿以外の機能を使えるわけでもない。あくまで「既存のブログ記事を X 上でリーチを広げるための配信コスト」だ。

この費用が見合うのは、X 経由の流入がアフィリエイト収益や広告収益に繋がるケースに限られる。個人ブログで収益化していない場合、月 3 USD を垂れ流し続けるだけになる可能性がある。「X に自動投稿したい」という動機だけで導入する前に、自分のブログに収益経路があるかどうかを確認しておくのが現実的だ。

毎日投稿するような用途だと月 20〜30 USD になるので特に注意。

関連アイテム

  • Raspberry Pi 5 4GB(Amazonで確認)— 自動投稿を 24 時間稼働させたいなら低消費電力の常時稼働マシンとして

よくある質問

Q. 無料で X API を使う方法はありますか?

A. 2026年5月時点では Free tier が存在しないため、クレジットカード登録と課金が必須です。従量課金なので月の投稿数を絞れば数ドル以内に収まります。

Q. 401 エラーが消えません

A. 初期設定後に 401 が出ることがあります。Developer Portal → App → Keys and tokens で Access Token & Secret を「Regenerate」してください。権限変更の有無に関わらず、再発行すると解消します。

Q. 画像なしのテキストツイートも 0.20 USD かかりますか?

A. 画像の有無は料金に影響しません。料金が変わるのは URL の有無です。URL なしのツイートは URL 付き(0.20 USD/件)より安い課金体系になっています。

Q. tweepy 以外のライブラリを使えますか?

A. requests で直接 OAuth を組んでも動きますが、tweepy を使うと認証まわりのボイラープレートを省けます。現時点では tweepy が最もメンテされているため推奨します。

※本記事の手順・コードは執筆時点(2026 年 5月)で動作確認していますが、X API の料金体系・tweepy の仕様は変更される可能性があります。動かない場合は コメント欄でお知らせください。

まとめ

2026年の X API は実質有料。「無料でボットを動かす」のは過去の話になった。ただし月数件の投稿なら数ドルで収まる。認証でハマる罠は 2 つあって、どちらもドキュメントで見つけにくい。tweepy で画像付き投稿するには v1.1 と v2 を組み合わせる必要があるのも現状の制約だ。

この記事が役に立ったら X(Twitter)でシェアしてもらえると喜びます。

このブログを書いた人のアプリ

本ブログの著者が作った iOS 読書管理アプリ わたしのほんやさん を App Store で公開しています。本棚をシンプルに管理したい方はぜひ。

App Store で見る →

関連記事

参考

※この記事は Claude Code を使った自動更新を試しています。

Text to Speech が OS の音声をミュートする問題を直した話【iOS audio session 衝突・2026年版】

この記事は iOS アプリ開発者向けです。7年前のアプリを近代化したあと、AdMob・カメラ・Text to Speech が audio session を奪い合って「起動直後に音楽がミュートされる」「読み上げ後に音楽が戻らない」が同時発生した。原因は iOS の Text to Speech が shared session を deactivate しない Apple 既知バグ(FB14444620)で、ワークアラウンド1行で解決。Build 19〜29 にわたる 11 回の試行錯誤を全部書く。

わたしのほんやさん App Store 掲載画像

この記事でわかること

  • AdMob・カメラ・音声読み上げが audio session を奪い合う構造と解決策
  • Apple 既知バグ FB14444620(音声読み上げが shared session を deactivate しない)のワークアラウンド
  • コードを直しても実機を再起動しないと確認できなかった話

前提:何を直したアプリか

わたしのほんやさんは2017年リリースの iOS 読書管理アプリ。v3.0.0 でコードベースを Xcode 26 / Swift 6 / SPM に全面移行したあと、v3.1.0 では Amazon アフィリエイト URL の現行化・AdMob 再有効化・Firebase Analytics 追加という3本立ての小規模リリースを予定していた。

実機で AdMob の動作確認をしようとしたところ、7年間ほぼ触っていなかった音声読み上げ・カメラまわりの問題が一気に露出した。結果として Build 19〜29 まで 11 サイクルを回すことになった。Build 19〜24 でカメラ・権限まわりの問題を片付け、Build 25〜29 が audio session との格闘だった。

audio session の三つ巴

症状は2つ同時に出た。

  • 起動直後にシステムで再生中の音楽がミュートされる
  • バーコードスキャンで本のタイトルを読み上げた後、音楽が再開しない

登場人物は3つ。全員が同じ AVAudioSession.sharedInstance() を取り合っている。

コンポーネント audio session への干渉
GADMobileAds.start()(AdMob) SDK 内部で .soloAmbient に上書きする(2020年代 AdMob の既知挙動)
AVCaptureSession(カメラ) デフォルトで audio session を自動設定しようとする
AVSpeechSynthesizer(読み上げ) speak() のたびに shared session をアクティブ化するが、終了後に deactivate しない

試行錯誤の記録(Build 25〜29)

Build 試した対処 結果
25 AVCaptureSession.automaticallyConfiguresApplicationAudioSession = false 効果なし
26 起動時に .ambient + audioSessionIsApplicationManaged = true を AdMob 前に設定 効果なし
27 didFinishsetActive(false, .notifyOthersOnDeactivation) 効果なし
28 speak() 前に shared session を事前アクティブ化 逆効果(症状悪化)
29 synthesizer.usesApplicationAudioSession = false 解決

原因:Apple 既知バグ FB14444620

Apple の公式ドキュメントは「setActive(false, .notifyOthersOnDeactivation) を呼べば他アプリが再開する」と書いている。これは正しい。しかし AVSpeechSynthesizer自分が session をアクティブ化した事実を覚えておらず、終了後に deactivate を呼ばない。公式ドキュメントに記載はないが、Apple Developer Forums で Apple エンジニアが認め、ワークアラウンドを提示している既知問題(FB14444620)だ。

解決策:synthesizer を shared session から切り離す

// synthesizer が shared audio session を触らないようにする
synthesizer.usesApplicationAudioSession = false

// 読み上げ終了後に他アプリへ「再開してよい」シグナルを送る
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer,
                       didFinish utterance: AVSpeechUtterance) {
    try? AVAudioSession.sharedInstance().setActive(
        false, options: .notifyOthersOnDeactivation)
}

func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer,
                       didCancel utterance: AVSpeechUtterance) {
    try? AVAudioSession.sharedInstance().setActive(
        false, options: .notifyOthersOnDeactivation)
}

usesApplicationAudioSession = false で synthesizer が独自の private session を使うようになり、shared session への干渉が消える。

Build 25〜28 の対処はいずれも Claude Code が「これで直るはず」と判断してコミットしたものだが、実機では一度も音楽が戻らなかった——コードが間違っていたからだ。Build 29 のコードを入れた後も最初は同じ症状が続いたが、実機を再起動したところ正常に動いた。audio session の状態が OS に残留していたためで、再起動でクリアされた形だった。コードが正しくても、実機を再起動しないと確認できない——これが最終的な教訓だった。

その他の修正

カメラ許可後にプレビューが出ない

AVCaptureSession.startRunning() はメインスレッド禁止なので専用バックグラウンドキューで呼んでいたが、その後の setupCameraLayout()(プレビュー UIView の再配置)も同じキューで呼ばれていた。UIKit の操作はメインスレッドのみなので、これが原因でプレビューが出なかった。

sessionQueue.async {
    self.captureSession.startRunning()
    DispatchQueue.main.async {
        self.setupCameraLayout()   // UI 操作はメインスレッドに戻す
    }
}

初回起動の連続ポップアップ

7年前にリリースしたころ、iOS のユーザー許諾ポップアップはほぼカメラ権限だけだった。その後 Apple は ATT(広告トラッキング許諾)・通知許諾・位置情報と、ユーザー同意を必要とするケースを年々追加してきた。古いアプリをそのまま動かし続けていると気づきにくいが、近代化して初めて実機を触ると4つのダイアログが一気に出る——これはコードのバグではなく、iOS のマイグレーションによる「許諾負債」の露出だ。

今回出た4つ:カメラアクセス(バーコードスキャン)、ATT(AdMob の広告トラッキング)、位置情報(近くのリアル図書館を検索する機能)、通知許諾——これが初回起動で連続して表示されていた。対処の構造:

  • 通知許諾:2回目起動以降に延期(初回はアプリの価値を理解していない)
  • 位置情報:図書館検索ボタンを押したタイミングでのみリクエスト
  • ATTapplicationDidBecomeActive で呼ぶ(カメラ権限と衝突しないタイミング)

よくある質問

Q. Text to Speech で音楽が止まる問題、iOS のバージョンは関係しますか?

A. FB14444620 は iOS 17 以降で特に顕在化しています。AdMob SDK のバージョンとの組み合わせに依存する部分もあるため、まず usesApplicationAudioSession = false を試してください。

Q. usesApplicationAudioSession = false にすると何か副作用はありますか?

A. synthesizer が private session を使うため、他のオーディオとのカテゴリ調整が不要になります。読み上げ中に BGM を混在させたい場合は調整が必要ですが、「読み上げ後に音楽を戻す」だけが目的なら副作用はほぼありません。

Q. Build 29 のコードを適用しても症状が続く場合は?

A. 実機を完全に再起動してから確認してください。audio session の状態が OS に残留しており、アプリの再起動だけでは解消しないことがあります。

※本記事の手順・コードは執筆時点(2026年5月)の Xcode 26 / iOS 17 / AdMob SDK 11.x 環境で動作確認しています。ライブラリのバージョンが変わると挙動が変わる可能性があります。動かない場合はコメント欄でお知らせください。

まとめ

v3.1.0 の動作確認で実機を触ったら 11 回の TestFlight 提出になった。根本原因は Text to Speech が shared audio session を deactivate しない Apple 既知バグ(FB14444620)で、usesApplicationAudioSession = false の1行で解決した。7年前の設計をそのまま移植した結果、iOS 17 + AdMob の新しい組み合わせで初めて顕在化した形だった。

この記事が役に立ったら X(Twitter)でシェアしてもらえると喜びます。

このブログを書いた人のアプリ

本ブログの著者が作った iOS 読書管理アプリ わたしのほんやさん を App Store で公開しています。本棚をシンプルに管理したい方はぜひ。

App Store で見る →

関連記事

参考

※この記事は Claude Code を使った自動更新を試しています。