Instagram へ自動投稿する機能を開発する

## はじめに
この記事は自分の作業メモです。
元々、TwitterとFacebookへの自動投稿は機能として持ってましたが、Instagram への自動投稿も欲しいということで作成しました。

まずは 生成AIさんにやり方を質問します。
```
インスタグラムのAPIを利用して、自動投稿を行いたいです。できるだけ詳しい手順を教えてください。
```
結果を見ていきながら、実装していきます。

## 開発者登録と権限設定

**Meta for Developersアカウント作成**
[開発者ポータル](https://developers.facebook.com/)にログイン後、「アプリの作成」から新規アプリを登録
これはFacebookk投稿で元々アプリを作成してました。

**「アプリに製品を追加」の「Instagram」を追加**
追加すると、右のメニューに「Instagram」が追加されます。

![スクリーンショット 2025-04-25 10.02.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1005574/c1d169a1-da3f-49e9-84e4-cea7969ffe6c.png)

**必要な情報を登録し、アクセストークンを生成する**
「InstagramビジネスログインによるAPI設定」で「Instagramアプリ名」などを登録すると「InstagramアプリID」や「Instagram app secret」を取得できます。
![スクリーンショット 2025-04-25 10.09.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1005574/56b87088-a672-475c-9c9c-832933d0fa91.png)


また、 「アクセストークンを生成する」でFacebookアカウントを紐付けると「長期アクセストークン」を取得できます。APIを利用して投稿する場合はこのアクセストークンを利用します。（私はここで生成されるトークンは短期アクセストークンと思ってましたが、ここで表示されるトークンは「長期アクセストークン」でした）

![スクリーンショット 2025-04-25 10.09.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1005574/cf4bba24-bf81-4b12-af5d-94743c29f5aa.png)

なお、「 Instagramビジネスログインを設定する」を利用すると、OAuth認証を利用して、「短期アクセストークン」を生成できます。こちらの場合は「短期アクセストークン」から「長期アクセストークン」を生成することを忘れないようにしましょう。

![スクリーンショット 2025-04-25 10.11.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1005574/7ebd6eff-1349-4bb8-8f6d-93552770db39.png)

## トークンの管理
生成したトークンはデータベースに保存するようにします。これは、長期アクセストークンは永続トークンではなく、定期的に更新しなければならないためです。

以下のマイグレーションファイルを作成しました。
```
class CreateInstagramTokens < ActiveRecord::Migration
  def change
    create_table :instagram_tokens do |t|
      t.string :access_token, null: false
      t.string :refresh_token
      t.integer :expires_in
      t.string :scope
      t.datetime :expires_at
      t.json :raw_response
      t.boolean :active, default: true
      t.string :instagram_user_id, :string

      t.timestamps
    end

    add_index :instagram_tokens, :active
  end
end
```

## APIを呼び出すクライアントを作成
実際にAPI呼び出しには以下のクライアントを介するようにしました。（なお、コードは生成AIで作成しています）
なお、`client_id` は `InstagramアプリID` であり、`client_secret` は `Instagram app secret` です。それぞれ、環境変数として登録します。
```
require 'faraday'
require 'json'

module Instagram
  class InstagramError < StandardError; end
  class TokenError < InstagramError; end
  class ApiError < InstagramError; end
  class ParseError < InstagramError; end

  class Client
    BASE_URL = 'https://graph.instagram.com'.freeze
    AUTH_URL = 'https://www.instagram.com'.freeze
    API_URL = 'https://api.instagram.com'.freeze
    API_VERSION = 'v18.0'.freeze

    attr_reader :access_token, :client_id, :client_secret, :user_id

    def initialize(access_token = nil, user_id = nil)
      @client_id = ENV['instagram__app_id']
      @client_secret = ENV['instagram__app_secret']
      
      # アクセストークンの取得優先順位:
      # 1. 引数で渡されたトークン
      # 2. データベースの最新のアクティブなトークン
      @access_token = access_token
      @user_id = user_id
      @conn = create_connection()
    end

    # ユーザー情報を取得
    def me
      response = @conn.get('/v22.0/me')
      handle_response(response)
    end

    # メディアを投稿（Instagram Graph APIでは直接投稿できないため、Facebookページを経由）
    def create_media(image_url, caption)
      
      # コンテナを作成（投稿準備）
      response = @conn.post do |req|
        req.url "/#{user_id}/media"
        req.params = {
          access_token: access_token,
          image_url: image_url,
          caption: caption
        }
      end
      
      result = handle_response(response)
      return nil unless result && result['id']
      
      creation_id = result['id']
      publish_media(creation_id)
    end

    # 複数画像の投稿
    def create_carousel(image_urls, caption)
      # Instagramの制限：カルーセル投稿は最大10枚まで
      if image_urls.size > 10
        Rails.logger.warn("Instagram carousel post limited to 10 images, truncating #{image_urls.size} to 10")
        image_urls = image_urls.take(10)
      end
      
      # 各画像のコンテナIDを取得
      Rails.logger.info("Creating carousel with #{image_urls.size} images")
      media_ids = []
      
      image_urls.each_with_index do |url, index|
        begin
          Rails.logger.info("Creating carousel item #{index + 1}/#{image_urls.size}: #{url}")
          response = @conn.post do |req|
            req.url "/#{user_id}/media"
            req.params = {
              access_token: access_token,
              image_url: url,
              is_carousel_item: true
            }
          end
          
          result = handle_response(response)
          if result && result['id']
            media_ids << result['id']
            Rails.logger.info("Successfully created carousel item #{index + 1}, container ID: #{result['id']}")
          else
            Rails.logger.error("Failed to create carousel item #{index + 1}")
          end
        rescue StandardError => e
          Rails.logger.error("Error creating carousel item #{index + 1}: #{e.message}")
        end
      end
      
      if media_ids.empty?
        Rails.logger.error("No valid media containers created, aborting carousel post")
        return nil
      end
      
      # カルーセル投稿を作成
      Rails.logger.info("Creating carousel container with #{media_ids.size} items")
      begin
        response = @conn.post do |req|
          req.url "/#{user_id}/media"
          req.params = {
            access_token: access_token,
            caption: caption,
            media_type: 'CAROUSEL',
            children: media_ids.join(',')
          }
        end
        
        result = handle_response(response)
        unless result && result['id']
          Rails.logger.error("Failed to create carousel container")
          return nil
        end
        
        creation_id = result['id']
        Rails.logger.info("Successfully created carousel container, ID: #{creation_id}")
        
        # カルーセルを公開
        Rails.logger.info("Publishing carousel")
        publish_result = publish_media(creation_id)
        
        if publish_result && publish_result['id']
          Rails.logger.info("Successfully published carousel, ID: #{publish_result['id']}")
        else
          Rails.logger.error("Failed to publish carousel")
        end
        
        publish_result
      rescue StandardError => e
        Rails.logger.error("Error creating or publishing carousel: #{e.message}")
        nil
      end
    end

    # 投稿を公開
    def publish_media(creation_id)  
      response = @conn.post do |req|
        req.url "/#{user_id}/media_publish"
        req.params = {
          access_token: access_token,
          creation_id: creation_id
        }
      end
      
      handle_response(response)
    end

    # 投稿一覧を取得
    def media(limit = 25)    
      response = @conn.get do |req|
        req.url "/#{user_id}/media"
        req.params = {
          access_token: access_token,
          fields: 'id,caption,media_type,media_url,permalink,thumbnail_url,timestamp,username',
          limit: limit
        }
      end
      
      handle_response(response)
    end

    # 特定の投稿の詳細を取得
    def media_details(media_id)
      response = @conn.get do |req|
        req.url "/#{media_id}"
        req.params = {
          access_token: access_token,
          fields: 'id,caption,media_type,media_url,permalink,thumbnail_url,timestamp,username'
        }
      end
      
      handle_response(response)
    end

    # アクセストークンを更新（長期トークンの取得）
    def refresh_token
      response = @conn.get do |req|
        req.url '/refresh_access_token'
        req.params = {
          grant_type: 'ig_refresh_token',
          access_token: access_token
        }
      end
      
      result = handle_response(response)
      if result && result['access_token']
        @access_token = result['access_token']
        @conn = create_connection
      end
      
      result
    end
    
    # 認証コードからアクセストークンを取得
    def exchange_code_for_token(code, redirect_uri)
      if client_id.blank? || client_secret.blank?
        raise ArgumentError, "Client ID and Client Secret are required for code exchange"
      end
      
      api_conn = Faraday.new(url: API_URL)
      response = api_conn.post("/oauth/access_token") do |req|
        req.body = {
          client_id: client_id,
          client_secret: client_secret,
          redirect_uri: redirect_uri,
          code: code,
          grant_type: 'authorization_code'
        }
      end
      
      result = handle_api_response(response)
      if result && result['access_token']
        @access_token = result['access_token']
        @conn = create_connection
      end
      
      result
    end
    
    # 短期アクセストークンから長期アクセストークンを取得
    def exchange_token(short_lived_token = nil)
      token_to_exchange = short_lived_token || access_token
      
      if client_id.blank? || client_secret.blank?
        raise ArgumentError, "Client ID and Client Secret are required for token exchange"
      end

      response = @conn.get do |req|
        req.url "/access_token"
        req.params = {
          grant_type: 'ig_exchange_token',
          client_secret: client_secret,
          access_token: token_to_exchange
        }
      end
      
      result = handle_response(response)
      if result && result['access_token']
        @access_token = result['access_token']
        @conn = create_connection
      end
      
      result
    end
    
    # 認証URLを生成
    def self.generate_auth_url(redirect_uri)
      client_id = ENV['instagram__app_id']
      
      if client_id.blank?
        raise ArgumentError, "Client ID is required for generating auth URL"
      end
      
      # Instagram Business Login用のURLを生成
      AUTH_URL + 
      "/oauth/authorize?" +
      "enable_fb_login=0" +
      "&force_authentication=1" +
      "&client_id=#{client_id}" +
      "&redirect_uri=#{CGI.escape(redirect_uri)}" +
      "&scope=#{CGI.escape('instagram_business_basic,instagram_business_manage_messages,instagram_business_manage_comments,instagram_business_content_publish,instagram_business_manage_insights')}" +
      "&response_type=code" +
      "&state=#{SecureRandom.hex(10)}"
    end
    
    # ユーザーのInstagramアカウント情報を取得
    def get_user_account_info
      response = @conn.get do |req|
        req.url '/me'
        req.params = {
          fields: 'id,username',
          access_token: access_token
        }
      end
      
      handle_response(response)
    end
    
    # 認証コードからInstagram Business Accountまでの全プロセスを実行
    def process_auth_code(code, redirect_uri)
      # 認証コードをアクセストークンに交換
      token_result = exchange_code_for_token(code, redirect_uri)
      return nil unless token_result && token_result['access_token'].present?
      
      short_lived_token = token_result['access_token']
      user_id = token_result['user_id'] # Instagram APIはユーザーIDも返す
      
      # 短期トークンを長期トークンに交換
      long_lived_result = exchange_token(short_lived_token)
      return nil unless long_lived_result && long_lived_result['access_token'].present?
      
      # ユーザーIDが取得できなかった場合はAPIから取得
      if user_id.nil?
        begin
          # アクセストークンを使ってユーザー情報を取得
          @access_token = long_lived_result['access_token']
          @conn = create_connection
          
          user_info = get_user_account_info
          user_id = user_info['id'] if user_info && user_info['id'].present?
        rescue => e
          Rails.logger.error("Failed to fetch Instagram user ID: #{e.message}")
        end
      end
      
      # 結果を返す
      {
        'access_token' => long_lived_result['access_token'],
        'refresh_token' => long_lived_result['refresh_token'],
        'expires_in' => long_lived_result['expires_in'],
        'scope' => long_lived_result['scope'],
        'instagram_user_id' => user_id,
        'raw_response' => long_lived_result
      }
    end

    private

    def create_connection
      Faraday.new(url: BASE_URL) do |faraday|
        faraday.request :url_encoded
        faraday.response :logger if Rails.env.development?
        faraday.adapter Faraday.default_adapter
      end
    end

    def handle_response(response)      
      begin
        res = JSON.parse(response.body)
        unless response.status.between?(200, 299)
          error_message = res.dig("error", "message") || response.body
          Rails.logger.error("Instagram API request failed: #{response.status} - #{error_message}")
          
          # セッションキー無効エラーの場合はTokenErrorを発生
          if error_message.include?("Session key invalid")
            raise TokenError, "Instagram token is invalid or expired: #{error_message}"
          else
            raise ApiError, "Instagram API request failed: #{response.status} - #{error_message}"
          end
        end
        res
      rescue JSON::ParserError => e
        Rails.logger.error("Instagram API response parsing error: #{e.message}")
        raise ParseError, "Instagram API response parsing error: #{e.message}"
      end
    end
    
    def handle_api_response(response)
      begin
        res = JSON.parse(response.body)
        unless response.status.between?(200, 299)
          error_message = res.dig("error", "message") || res.dig("error_message") || response.body
          Rails.logger.error("Instagram API request failed: #{response.status} - #{error_message}")
          raise ApiError, "Instagram API request failed: #{response.status} - #{error_message}"
        end
        res
      rescue JSON::ParserError => e
        Rails.logger.error("Instagram API response parsing error: #{e.message}")
        raise ParseError, "Instagram API response parsing error: #{e.message}"
      end
    end
  end
end
```

## トークン更新タスク
トークンを更新するタスクも作成しました。
```
namespace :instagram do
  desc 'Refresh Instagram access token to prevent expiration'
  task refresh_token: :environment do
    token = InstagramToken.current
    
    if token.blank?
      puts "No active Instagram token found."
      next
    end
    
    if token.expires_at.present? && token.expires_at > 1.week.from_now
      puts "Instagram token is still valid for more than a week (expires at #{token.expires_at}). Skipping refresh."
      next
    end
    
    begin
      puts "Refreshing Instagram token (ID: #{token.id}, created at: #{token.created_at})..."
      client = Instagram::Client.new(token.access_token)
      result = client.refresh_token
      
      if result && result['access_token'].present?
        token.update_from_response(result)
        puts "Instagram token successfully refreshed. New expiration: #{token.expires_at}"
      else
        puts "Failed to refresh Instagram token. No valid response received."
      end
    rescue StandardError => e
      puts "Error refreshing Instagram token: #{e.message}"
    end
  end
```


## テスト
トークンを登録し、実際に投稿できるか試してみます。
以下のような画面を用意し、指定した画像を投稿してみました。


![スクリーンショット 2025-04-25 10.27.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1005574/ac03929f-37ce-43a1-a035-216a1489e066.png)

結果
![スクリーンショット 2025-04-25 10.30.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1005574/3fc503e4-6644-47d2-9700-a10a39ecc3ac.png)

ちゃんと投稿できました。

## まとめ
この記事では Instagram への投稿をAPIで実施しました。
Facebookとは異なり、永続トークンが存在しないため、めんどくさいなと思ってましたが、生成AIを利用することでかなりの部分を機械的に作成できました。
もちろん、生成AIだけでは URLを間違っていたりして動作しませんので、そのあたりはドキュメントの内容を提示して修正を行いました。
次は TikTok の API 投稿もやってみようと思います。