0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Instagramの予約投稿を状態遷移で作る。処理完了待ち付きの公開API・失敗時の再試行・トークン失効の反映(Laravel)

0
Posted at

Instagram Graph APIで画像を投稿する機能に、「予約投稿」と「失敗時の再試行」を足したときの構成メモです。投稿の状態をstatusカラムで管理し、各操作をその遷移として整理しています。

Instagramの画像投稿は、作る→待つ→公開の3ステップ

Instagram APIの画像投稿は、1回のリクエストでは終わりません。

  1. /{ig-user-id}/media で「コンテナ」(投稿の下書き)を作る → creation_idが返る
  2. /{creation_id}?fields=status_code で、コンテナの処理がFINISHEDになるまで待つ
  3. /{ig-user-id}/media_publish にcreation_idを渡して公開する → 投稿のmedia_idが返る

2番目の「待つ」は、最初の版にはありませんでした。コンテナを作った直後に公開を呼ぶと、画像の取り込みが終わっておらず、Media ID is not availableで失敗することがあるためです。

// app/Services/InstagramPublishingService.php
$container = Http::asForm()->post(self::API_BASE."/{$account->instagram_user_id}/media", [
    'image_url' => $imageUrl,
    'caption' => $caption,
    'access_token' => $accessToken,
])->throw()->json();

$creationId = $container['id'] ?? throw new InstagramPublishException('コンテナ作成のレスポンスが不正です。', 'unknown');

// 画像の取り込みが終わる前にmedia_publishを呼ぶと「Media ID is not available」で失敗するため、先に完了を待つ
$this->waitUntilContainerIsReady($creationId, $accessToken);

$published = Http::asForm()->post(self::API_BASE."/{$account->instagram_user_id}/media_publish", [
    'creation_id' => $creationId,
    'access_token' => $accessToken,
])->throw()->json();

image_urlには、Instagram側のサーバーがアクセスできる公開URLを渡す必要があります。ローカルのファイルパスは使えないので、アップロード画像はpublicディスクに保存して、そのURLを渡しています。

待つ処理は、status_codeがFINISHEDなら公開へ進み、ERROR/EXPIREDなら公開せず失敗にします。状況が取れない、または既定の回数(15回、2秒おき)で終わらない場合は、そのまま公開を試します。

APIの呼び出しだけを担当するこのクラスは、成功したらmedia_idを返し、失敗したら独自の例外(エラー種別つき)を投げます。投稿の状態管理は、その外側のサービスに分けています。

状態は6つ

$table->enum('status', ['draft', 'scheduled', 'processing', 'published', 'failed', 'cancelled'])->default('draft');

操作ごとに、どの状態からどの状態へ動くかを整理するとこうなります。

操作 遷移
今すぐ投稿 保存時にprocessing → 成功ならpublished、失敗ならfailed
予約 scheduledで保存
予約時刻の到来(cron) scheduled → processing → published / failed
再試行 failed → processing → published / failed
取り消し cancelled

サービスクラスでは、これがそのままメソッドになっています。

public function publishNow(Customer $customer, UploadedFile $image, ?string $caption): SocialPost
{
    $post = $this->store($customer, $image, $caption, null, 'processing');
    $this->attemptPublish($post, $customer);

    return $post->fresh();
}

public function schedule(Customer $customer, UploadedFile $image, ?string $caption, DateTimeInterface $scheduledAt): SocialPost
{
    return $this->store($customer, $image, $caption, $scheduledAt, 'scheduled');
}

public function publishScheduled(SocialPost $post, Customer $customer): SocialPost
{
    $post->update(['status' => 'processing']);
    $this->attemptPublish($post, $customer);

    return $post->fresh();
}

public function retry(SocialPost $post, Customer $customer): SocialPost
{
    $post->update(['status' => 'processing', 'error_message' => null]);
    $this->attemptPublish($post, $customer);

    return $post->fresh();
}

4つのメソッドは、最初にstatusをセットして、すべて同じattemptPublish()に合流します。公開処理の本体が1か所なので、結果の書き込み(成功・失敗)が食い違いません。retry()だけは前回のエラーメッセージを消してから再実行します。

結果の書き込みは1か所

private function attemptPublish(SocialPost $post, Customer $customer): void
{
    $imageUrl = Storage::disk('public')->url($post->media_path);

    try {
        $result = $this->publishingService->publishImage($customer, $imageUrl, $post->caption);

        $post->update([
            'status' => 'published',
            'published_at' => now(),
            'provider_media_id' => $result['media_id'],
        ]);
    } catch (InstagramPublishException $e) {
        $post->update([
            'status' => 'failed',
            'error_message' => $e->getMessage(),
        ]);

        if ($e->isAuthExpired()) {
            $customer->instagramAccount?->update(['status' => 'expired']);
        }
    }
}

失敗時には、エラーの種別が「認証切れ」だった場合に限り、接続しているInstagramアカウント側のstatusもexpiredに更新します。アカウント接続の状態を実態に合わせておくためです。トークンが切れているのに「接続済み」のままだと、次の投稿も同じ理由で失敗し続けます。投稿の失敗が、アカウント接続の状態にも反映される作りです。

予約時刻の到来はcronで拾う

// app/Console/Commands/PublishDueInstagramPosts.php
$duePosts = SocialPost::with('customer')
    ->where('provider', 'instagram')
    ->where('status', 'scheduled')
    ->where('scheduled_at', '<=', now())
    ->get();

foreach ($duePosts as $post) {
    $post = $instagramPostService->publishScheduled($post, $post->customer);
    // 結果をログに出す
}
// routes/console.php
Schedule::command('instagram:publish-due-posts')->everyTwoMinutes();

status = 'scheduled'かつscheduled_at <= now()で拾い、publishScheduled()の最初にprocessingへ更新します。processingになった投稿は次回のcronの検索条件から外れるので、2分おきの実行が来ても同じ投稿を再度拾うことはありません。

ここで、正直に書いておくべき点があります。この構成ではwithoutOverlapping()を付けていません。1回の実行が2分を超えるほど予約が溜まると、次の実行と重なる可能性があります。予約が少ないうちは問題になりませんが、件数が増える場合は、スケジュールに->withoutOverlapping()を足すか、取得と同時にprocessingへ更新する処理(行ロックなど)を検討する余地があります。

まとめ

  • 画像投稿は「コンテナ作成→処理完了待ち→公開」の3ステップで、画像は公開URLで渡す
  • 投稿の状態をdraft / scheduled / processing / published / failed / cancelledで持ち、操作ごとの遷移を表にしてから実装する
  • 今すぐ・予約・再試行がすべて同じattemptPublish()に合流するので、結果の書き込みは1か所で済む
  • トークン失効のエラーは、投稿の失敗だけでなくアカウント接続の状態にも反映する
  • cronを短い間隔で回す場合は、重複実行への備えを先に決めておく

この記事はZennにも投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?