TEXTBOOK SECTION / AI LEARNING

運用を確認してアプリを完成させる

Flutterアプリケーション開発概論の「SNSトレンド自動収集」より、運用を確認してアプリを完成させるを解説。生成AI、AI活用、DX、業務改善を実践しながら学べるオンライン教材です。

7SNSトレンド自動収集Flutter / iOS / Android / MacOS / Windows / 基礎から学ぶ / 開発 / アプリ開発

OVERVIEW

この節で学べること

概要を表示する
項目内容
教材名Flutterアプリケーション開発概論
SNSトレンド自動収集
運用を確認してアプリを完成させる
カテゴリFlutter / iOS / Android / MacOS / Windows / 基礎から学ぶ / 開発 / アプリ開発
学習内容生成AI、AI活用、DX、業務改善を実践しながら理解するための教材です。

TABLE OF CONTENTS

目次

CONTENT

ここから

前ページでは、Supabaseに保存された情報をFlutterアプリから読み込み、話題度順の一覧、検索、情報源別の絞り込み、外部リンク、ブックマークを実装しました。

最後のページでは、アプリを継続して無料運用するための確認を行います。

確認する内容は次のとおりです。

運用確認
├─ GitHub Actionsが定期的に動いているか
├─ APIの呼び出し回数が増えすぎていないか
├─ Supabaseの保存容量が増えすぎていないか
├─ GitHub Actionsの実行時間が長くなっていないか
├─ 削除・非公開になった投稿を表示していないか
├─ 通信失敗から復旧できるか
├─ 利用規約や著作権へ配慮できているか
└─ Android・iOS・Webで正常に動くか

このページを終えると、次の流れがすべてつながります。

SNS・RSS
   ↓
GitHub Actionsによる定期収集
   ↓
データの統一・重複除去
   ↓
話題度の計算
   ↓
Supabaseへの保存
   ↓
Flutterアプリで表示

7.1 自動収集が動作しているか確認する

最初に、GitHub Actionsの定期収集が正常に動いているか確認します。

GitHubのリポジトリを開き、次の順番で確認します。

Actions
  ↓
Collect SNS Trends
  ↓
直近の実行履歴

正常に動作している場合、3時間ごとに実行履歴が追加されます。

成功
緑色のチェック

失敗
赤色のマーク

実行中
黄色または進行中の表示

手動実行を確認する

定期実行だけに頼らず、手動でも実行できることを確認します。

Actions
  ↓
Collect SNS Trends
  ↓
Run workflow
  ↓
Run workflow

手動実行が表示されない場合は、ワークフローファイルに次の設定があるか確認します。

on:
  workflow_dispatch:

  schedule:
    - cron: '17 */3 * * *'

GitHub Actionsのログを確認する

実行結果を開き、次のステップが成功しているか確認します。

Checkout repository
Set up Dart
Install dependencies
Run collector

収集処理のログには、情報源ごとの取得結果を表示します。

mastodon_tags: 10 items collected.
mastodon_statuses: 20 items collected.
bluesky:Flutter: 20 items collected.
bluesky:AI: 20 items collected.
rss:feed-name: 15 items collected.

Collection completed.
status=success
collected=85
saved=72
failedSources=0

collectedsavedが異なるのは、重複データを除去しているためです。

collected
└─ 各情報源から取得した合計件数

saved
└─ 同じIDを除去した後の保存件数

Supabaseの収集ログを確認する

Supabaseのcollection_logsテーブルを開きます。

次の情報が記録されていることを確認します。

カラム確認内容
statussuccesspartialfailed
started_at収集開始時刻
finished_at収集終了時刻
collected_count取得した合計件数
saved_count保存対象になった件数
failed_source_count失敗した情報源数
errors失敗内容

最新のログが3時間以上追加されていない場合は、次の項目を確認します。

確認項目
├─ ワークフローファイルがmainブランチにあるか
├─ GitHub Actionsが無効になっていないか
├─ リポジトリが長期間操作されていないか
├─ GitHub Secretsが設定されているか
├─ Dartの依存関係に問題がないか
└─ Supabaseプロジェクトが利用可能な状態か

収集状態を確認するスクリプトを作成する

GitHub Actionsの画面だけでなく、コマンドから最新状態を確認できるようにします。

次のファイルを作成します。

collector/bin/health_check.dart
import 'dart:io';

import 'package:sns_trend_collector/sns_trend_collector.dart';

/// 役割:
/// Supabaseに保存された最新の収集ログと投稿を確認する。
///
/// 入力:
/// SUPABASE_URLとSUPABASE_SECRET_KEY。
///
/// 出力:
/// 正常な場合は終了コード0。
/// 最終収集が古い、またはログがない場合は終了コード1。
Future<void> main() async {
  final environment = CollectorEnvironment.load();

  final supabase = SupabaseClientFactory.create(
    environment,
  );

  final Object? logResponse = await supabase
      .from('collection_logs')
      .select(
        'status, finished_at, saved_count, '
        'failed_source_count',
      )
      .order(
        'finished_at',
        ascending: false,
      )
      .limit(1);

  if (logResponse is! List<Object?> ||
      logResponse.isEmpty) {
    print('Collection log was not found.');
    exitCode = 1;
    return;
  }

  final latestLog = requireJsonMap(
    logResponse.first,
    context: 'Latest collection log',
  );

  final status = requireString(
    latestLog,
    'status',
    context: 'Latest collection log',
  );

  final finishedAt = parseIsoDateTime(
    optionalString(
      latestLog,
      'finished_at',
    ),
  );

  if (finishedAt == null) {
    print('Latest collection time is invalid.');
    exitCode = 1;
    return;
  }

  final now = DateTime.now().toUtc();
  final elapsed = now.difference(finishedAt);

  final savedCount =
      optionalInt(
        latestLog,
        'saved_count',
      ) ??
      0;

  final failedSourceCount =
      optionalInt(
        latestLog,
        'failed_source_count',
      ) ??
      0;

  print('status=$status');
  print('finishedAt=$finishedAt');
  print('savedCount=$savedCount');
  print('failedSourceCount=$failedSourceCount');
  print('elapsedMinutes=${elapsed.inMinutes}');

  const maximumAllowedDelay =
      Duration(hours: 8);

  if (elapsed > maximumAllowedDelay) {
    print(
      'Collection has not completed within '
      '${maximumAllowedDelay.inHours} hours.',
    );

    exitCode = 1;
    return;
  }

  if (status == 'failed') {
    print('Latest collection failed.');
    exitCode = 1;
    return;
  }

  print('Collection health check passed.');
}

第3ページのjson_reader.dartが公開されていない場合は、次のエクスポートを追加します。

export 'src/utils/json_reader.dart';

ローカルで実行します。

cd collector

dart run bin/health_check.dart

正常であれば、次のように表示されます。

status=success
savedCount=72
failedSourceCount=0
elapsedMinutes=35
Collection health check passed.

7.2 APIの使用量を確認する

無料運用を続けるには、APIの呼び出し回数を増やしすぎないことが重要です。

特に、次の変更には注意します。

API使用量が増える変更
├─ 検索キーワードを増やす
├─ Mastodonの取得サーバーを増やす
├─ RSSフィードを増やす
├─ 取得間隔を短くする
├─ 1回の取得ページ数を増やす
├─ エラー時の再試行回数を増やす
└─ YouTube検索を追加する

1日あたりの実行回数を計算する

現在のGitHub Actionsは、3時間ごとに実行します。

24時間 ÷ 3時間
= 1日8回

検索キーワードが3個の場合、Blueskyの検索は次の回数になります。

1回の収集
3キーワード

1日の収集
8回

3 × 8
= 1日24回

YouTubeも同じ3キーワードで検索すると、YouTube検索も1日24回発生します。

取得ページを増やすと、その分だけAPI呼び出し回数も増えます。

自己制限をコードで設定する

設定ミスによって取得対象が急増しないように、アプリ側で上限を設けます。

次のファイルを作成します。

collector/lib/src/config/collection_limits.dart
/// 役割:
/// 無料運用を維持するための収集対象上限を定義する。
abstract final class CollectionLimits {
  /// 1回の実行で検索する最大キーワード数。
  static const int maximumKeywordCount = 5;

  /// 1回の実行で取得する最大RSSフィード数。
  static const int maximumFeedCount = 20;

  /// 1回のキーワード検索で取得する最大件数。
  static const int maximumItemsPerSearch = 50;

  /// 役割:
  /// 収集設定が上限以内であることを確認する。
  ///
  /// 入力:
  /// キーワード、RSSフィード、1検索あたりの取得件数。
  ///
  /// 出力:
  /// 問題がない場合は何も返さない。
  /// 上限を超えた場合はStateError。
  static void validate({
    required List<String> keywords,
    required List<String> feedUrls,
    required int itemsPerSearch,
  }) {
    if (keywords.length > maximumKeywordCount) {
      throw StateError(
        'Too many keywords. '
        'Maximum: $maximumKeywordCount.',
      );
    }

    if (feedUrls.length > maximumFeedCount) {
      throw StateError(
        'Too many RSS feeds. '
        'Maximum: $maximumFeedCount.',
      );
    }

    if (itemsPerSearch < 1 ||
        itemsPerSearch > maximumItemsPerSearch) {
      throw StateError(
        'itemsPerSearch must be between '
        '1 and $maximumItemsPerSearch.',
      );
    }
  }
}

collect.dartで、収集開始前に確認します。

const monitoredKeywords = <String>[
  'Flutter',
  'AI',
  '医療',
];

const feedUrls = <String>[
  '実際に使用するRSSフィード',
];

const itemsPerSearch = 20;

CollectionLimits.validate(
  keywords: monitoredKeywords,
  feedUrls: feedUrls,
  itemsPerSearch: itemsPerSearch,
);

この上限は、各サービスが定める公式上限ではありません。

無料運用を維持するために、アプリ側で設定する独自の安全上限です。

APIの公式上限や無料利用条件は変更される可能性があるため、実装時点の管理画面と公式資料を確認します。

HTTP 429を記録する

HTTP 429は、短時間に多くのリクエストを送った場合などに返されることがあります。

第3ページで実装したResilientHttpClientでは、HTTP 429を受け取ると待機して再試行します。

HTTP 429
    ↓
Retry-Afterを確認
    ↓
指定時間だけ待機
    ↓
再試行

HTTP 429が頻繁に発生する場合は、再試行回数を増やすのではなく、次の設定を見直します。

見直す項目
├─ 取得間隔を長くする
├─ キーワードを減らす
├─ 取得件数を減らす
├─ 複数ページの取得をやめる
└─ 同じ検索結果を再利用する

7.3 Supabaseの無料枠を監視する

Supabaseでは、データベース容量、通信量、ストレージなどを確認します。

今回のアプリで主に増えるのは、次の2つです。

増加するデータ
├─ trend_items
└─ collection_logs

画像ファイル自体はSupabaseへ保存せず、画像URLだけを保存しています。

そのため、Supabase Storageの消費は抑えられます。

テーブルの件数を確認する

SupabaseのSQL Editorで、次のSQLを実行します。

select
  count(*) as trend_item_count
from public.trend_items;

収集ログの件数も確認します。

select
  count(*) as collection_log_count
from public.collection_logs;

情報源ごとの件数を確認します。

select
  source,
  count(*) as item_count
from public.trend_items
group by source
order by item_count desc;

特定の情報源だけが極端に増えている場合は、取得設定や重複判定を確認します。

テーブル容量を確認する

次のSQLで、テーブルが使用している容量を確認できます。

select
  relname as table_name,
  pg_size_pretty(
    pg_total_relation_size(
      relid
    )
  ) as total_size
from pg_catalog.pg_statio_user_tables
where schemaname = 'public'
order by
  pg_total_relation_size(
    relid
  ) desc;

表示例は次のようになります。

trend_items       12 MB
collection_logs    1 MB

容量が増え続ける場合は、保存期間を見直します。

古いデータを削除する

第4ページでは、次の保存期間を例として設定しました。

トレンド情報
180日

収集ログ
90日

これはアプリ側で決める運用値です。

現在の話題だけを表示するアプリであれば、保存期間を短くできます。

await trendRepository.deleteItemsCollectedBefore(
  DateTime.now().toUtc().subtract(
        const Duration(days: 180),
      ),
);

await logRepository.deleteLogsBefore(
  DateTime.now().toUtc().subtract(
        const Duration(days: 90),
      ),
);

ただし、削除処理を毎回行うと、収集処理のたびに不要なデータベース操作が発生します。

1日に1回だけ実行するようにします。

/// 役割:
/// UTC日付が変わった直後の収集でメンテナンスを行うか判定する。
///
/// 入力:
/// 現在日時。
///
/// 出力:
/// UTC時刻が0時から2時台の場合はtrue。
bool shouldRunDailyMaintenance(
  DateTime now,
) {
  final utcNow = now.toUtc();

  return utcNow.hour < 3;
}

collect.dartの保存処理後に追加します。

if (shouldRunDailyMaintenance(now)) {
  await trendRepository.deleteItemsCollectedBefore(
    now.subtract(
      const Duration(days: 180),
    ),
  );

  await logRepository.deleteLogsBefore(
    now.subtract(
      const Duration(days: 90),
    ),
  );

  print('Daily maintenance completed.');
}

GitHub Actionsが3時間ごとに動くため、UTCの0時から2時台に実行される処理で、1日1回のメンテナンスになります。

ただし、実行が遅延した場合やワークフローの時刻を変更した場合は、複数回実行される可能性があります。

削除処理は同じ条件で複数回実行しても問題が起こらない設計にします。


7.4 GitHub Actionsの実行時間を確認する

GitHub Actionsでは、実行回数だけでなく、1回あたりの処理時間も確認します。

3時間ごとの実行では、1日に8回動きます。

1回の処理が約1分なら、30日間の実行時間は次の目安になります。

1分 × 8回 × 30日
= 約240分

実際の時間は、APIの応答速度、再試行回数、取得件数などによって変わります。

実行時間をGitHubへ表示する

ワークフローファイルのRun collectorを、次のように変更します。

- name: Run collector
  shell: bash
  run: |
    START_TIME=$(date +%s)

    dart run bin/collect.dart

    END_TIME=$(date +%s)
    DURATION=$((END_TIME - START_TIME))

    echo "## SNS Trend Collection" >> "$GITHUB_STEP_SUMMARY"
    echo "" >> "$GITHUB_STEP_SUMMARY"
    echo "- Execution time: ${DURATION} seconds" >> "$GITHUB_STEP_SUMMARY"
    echo "- Completed at: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> "$GITHUB_STEP_SUMMARY"

GitHub Actionsの実行結果を開くと、実行時間がSummaryへ表示されます。

SNS Trend Collection

Execution time: 38 seconds
Completed at: 2026-07-24 03:17:42 UTC

実行時間が長くなった場合

以前は30秒だった処理が数分かかるようになった場合は、次の項目を確認します。

確認項目
├─ 検索キーワードが増えていないか
├─ RSSフィードが増えていないか
├─ HTTP 429が発生していないか
├─ 500番台エラーで再試行していないか
├─ Supabaseへの保存件数が増えていないか
├─ 重複除去前の件数が急増していないか
└─ 応答の遅い情報源がないか

収集処理を無制限に動かさない

ワークフローには、すでに次の制限を設定しています。

timeout-minutes: 10

10分を超えた場合は、GitHub Actionsが処理を終了します。

また、同じ収集処理が重ならないようにしています。

concurrency:
  group: collect-sns-trends
  cancel-in-progress: false

前の収集が終わっていない場合、次の実行は待機します。


7.5 投稿削除・非公開化へ対応する

SNS投稿は、取得後に次の状態へ変わることがあります。

投稿の状態変化
├─ 投稿者が削除する
├─ 投稿者がアカウントを非公開にする
├─ アカウントが削除される
├─ 管理者によって投稿が削除される
├─ 記事URLが変更される
└─ RSSから記事が消える

一度取得できた投稿を、無期限に公開し続けるべきではありません。

ただし、検索結果から消えただけで「削除された」と判断してはいけません。

検索結果にない
≠
投稿が削除された

検索結果の件数制限や並び順の変更によって、取得できなくなった可能性があるためです。

利用状態を保存するカラムを追加する

次のマイグレーションファイルを作成します。

supabase/migrations/202607240003_add_availability_status.sql
-- 役割:
-- 投稿が現在も公開されているかを管理する。

alter table public.trend_items
  add column if not exists availability_status text
  not null
  default 'active'
  check (
    availability_status in (
      'active',
      'unavailable',
      'unknown'
    )
  );

alter table public.trend_items
  add column if not exists availability_checked_at
  timestamptz;

alter table public.trend_items
  add column if not exists unavailable_at
  timestamptz;

create index if not exists
  trend_items_availability_status_index
on public.trend_items (
  availability_status
);

drop policy if exists
  "Public can read trend items"
on public.trend_items;

create policy
  "Public can read active trend items"
on public.trend_items
for select
to anon, authenticated
using (
  availability_status = 'active'
);

公開状態は、次の3種類です。

active
└─ 公開されていることを確認できた

unavailable
└─ 削除または非公開を確認できた

unknown
└─ 現在の状態を確認できない

Flutterアプリから読み取れるのは、activeだけです。

再取得できた投稿を公開状態へ戻す

TrendRepository.upsertItemsの保存データへ、次の値を追加します。

final rows = batch.map((item) {
  return <String, Object?>{
    ...item.toJson(),
    'updated_at': updatedAt,
    'availability_status': 'active',
    'availability_checked_at': updatedAt,
    'unavailable_at': null,
  };
}).toList(growable: false);

過去に一時的に取得できなかった投稿が、再び取得できた場合はactiveへ戻ります。

削除を確認した投稿を非表示にする

TrendRepositoryへ次のメソッドを追加します。

/// 役割:
/// 削除または非公開を確認した投稿を非表示状態へ変更する。
///
/// 入力:
/// TrendItemのIDと確認日時。
///
/// 出力:
/// 更新完了を表すFuture。
Future<void> markUnavailable({
  required String id,
  required DateTime checkedAt,
}) async {
  final checkedAtValue =
      checkedAt.toUtc().toIso8601String();

  await _supabase
      .from('trend_items')
      .update(
    <String, Object?>{
      'availability_status': 'unavailable',
      'availability_checked_at':
          checkedAtValue,
      'unavailable_at': checkedAtValue,
      'updated_at': checkedAtValue,
    },
  ).eq(
    'id',
    id,
  );
}

次の状態を確認できた場合に使用します。

非表示にできる例
├─ 投稿個別取得APIが削除を返した
├─ 投稿個別取得APIが非公開を返した
├─ 元記事がHTTP 404になった
└─ 元記事がHTTP 410になった

次の状態だけでは、unavailableにしません。

非表示にしてはいけない例
├─ 検索結果に表示されなかった
├─ 一時的なタイムアウト
├─ HTTP 429
├─ HTTP 500
├─ DNSエラー
└─ 収集プログラムの不具合

通信失敗時はunknownとして扱うか、現在の状態を維持します。

全投稿を毎回確認しない

保存済みの全投稿へ毎回アクセスすると、API使用量とGitHub Actionsの実行時間が増えます。

MVPでは、次の方針にします。

通常の定期収集
└─ 新着・トレンド情報だけを取得する

公開状態の確認
├─ 上位投稿
├─ 最近取得した投稿
└─ 一定期間ごとの少数確認

過去180日分の全投稿を3時間ごとに確認してはいけません。


7.6 通信失敗時の再試行を確認する

第3ページでは、次のエラーを再試行対象にしました。

再試行する
├─ タイムアウト
├─ HTTP 429
├─ HTTP 500
├─ HTTP 502
├─ HTTP 503
└─ HTTP 504

次のエラーは、原則として同じ内容で再試行しません。

再試行しない
├─ HTTP 400
├─ HTTP 401
├─ HTTP 403
├─ HTTP 404
└─ 不正なJSON

Retry-Afterの日付形式へ対応する

Retry-Afterは、秒数だけでなく日時で返される場合があります。

第3ページの_calculateRetryDelayを、次のように更新します。

import 'dart:io';

/// 役割:
/// Retry-Afterまたは指数バックオフから待機時間を決める。
///
/// 入力:
/// HTTPレスポンス、試行回数、現在日時。
///
/// 出力:
/// 最大60秒の待機時間。
Duration _calculateRetryDelay({
  required http.Response response,
  required int attempt,
  DateTime? now,
}) {
  final retryAfter =
      response.headers['retry-after'];

  if (retryAfter != null) {
    final seconds = int.tryParse(
      retryAfter,
    );

    if (seconds != null && seconds > 0) {
      return _limitRetryDelay(
        Duration(seconds: seconds),
      );
    }

    try {
      final retryDate = HttpDate.parse(
        retryAfter,
      ).toUtc();

      final currentTime =
          (now ?? DateTime.now()).toUtc();

      final difference = retryDate.difference(
        currentTime,
      );

      if (!difference.isNegative) {
        return _limitRetryDelay(
          difference,
        );
      }
    } on FormatException {
      // 不正なRetry-Afterは無視して
      // 指数バックオフを使用する。
    }
  }

  return _calculateExponentialDelay(
    attempt,
  );
}

/// 役割:
/// 待機時間を1秒から60秒の範囲へ制限する。
///
/// 入力:
/// APIから指定された待機時間。
///
/// 出力:
/// 制限後の待機時間。
Duration _limitRetryDelay(
  Duration duration,
) {
  if (duration < const Duration(seconds: 1)) {
    return const Duration(seconds: 1);
  }

  if (duration > const Duration(seconds: 60)) {
    return const Duration(seconds: 60);
  }

  return duration;
}

情報源ごとに失敗を分離する

一つの情報源が失敗しても、ほかの収集は続けます。

Mastodon
└─ 成功

Bluesky
└─ HTTP 429で失敗

RSS
└─ 成功

YouTube
└─ 成功

この場合、収集結果はpartialです。

status = partial

GitHub Actions自体を失敗にするかどうかは、運用方針によって決めます。

本教材では、次の方針にします。

一部成功
└─ GitHub Actionsは完了させる

全情報源が失敗
└─ 終了コード1

Supabaseへの保存に失敗
└─ 終了コード1

一部のAPI障害だけで、正常に取得できた情報まで破棄しないためです。

ワークフロー全体を安易に再実行しない

収集プログラムには、情報源ごとの再試行がすでにあります。

GitHub Actionsでさらに処理全体を何度も再実行すると、次の問題が起こります。

  • API呼び出し回数が増える
  • YouTubeの利用量が増える
  • 同じ失敗を繰り返す
  • GitHub Actionsの実行時間を消費する
  • HTTP 429を悪化させる

再試行は、できる限りHTTPクライアント内部で制御します。


7.7 利用規約・著作権・個人情報を確認する

技術的に取得できる情報であっても、自由に保存・再表示できるとは限りません。

公開前に、情報源ごとの利用条件を確認します。

公式の取得方法を使用する

本教材では、次の方法を使用しています。

使用する
├─ 公式API
├─ 公開API
├─ RSS
└─ Atom

使用しない
├─ ログイン画面のスクレイピング
├─ CAPTCHAの回避
├─ 非公式API
├─ Bot対策の回避
└─ 非公開投稿の取得

APIが提供されている場合でも、次の条件がある可能性があります。

  • 再表示できる項目
  • 保存できる期間
  • 商用利用の可否
  • 投稿削除時の対応
  • 投稿者名や画像の表示方法
  • キャッシュ可能な期間
  • 必要な出典表示
  • 利用できる地域

公開前に、実際に使用する情報源ごとに確認します。

記事全文を複製しない

RSSに記事全文が含まれていても、そのままアプリへ転載する設計にはしません。

本教材では、次の情報だけを表示します。

表示する
├─ タイトル
├─ 短い概要
├─ 配信元
├─ 公開日時
├─ サムネイルURL
└─ 元記事へのリンク

詳細は、元サイトで確認してもらいます。

アプリ
  ↓
元記事を見る
  ↓
配信元のWebサイト

出典を明確にする

投稿カードには、必ず情報源を表示します。

Mastodon
Bluesky
RSS
YouTube

SNS投稿や記事を、アプリ運営者が作成した情報のように見せてはいけません。

必要以上の個人情報を保存しない

今回保存する投稿者情報は、公開されている表示名や配信元名です。

次の情報は収集しません。

収集しない
├─ メールアドレス
├─ 電話番号
├─ 住所
├─ 非公開プロフィール
├─ 認証トークン
├─ IPアドレス
├─ 位置情報
└─ ログイン情報

投稿本文に個人情報が含まれている場合もあります。

そのため、次の対策を検討します。

  • 投稿本文を長期間保存しない
  • 通報・削除依頼の窓口を用意する
  • 非公開化を確認した投稿を表示しない
  • 個人名をランキング目的で集計しない
  • 医療・政治・犯罪などのセンシティブなテーマを慎重に扱う

プライバシーポリシーを用意する

一般公開する場合は、少なくとも次の内容を説明します。

プライバシーポリシー
├─ 収集する情報
├─ 収集しない情報
├─ 利用目的
├─ 保存期間
├─ 外部サービス
├─ ブックマークの保存場所
├─ 削除依頼の方法
└─ 問い合わせ先

ブックマークは端末内へ保存しているため、その点も明記します。

「トレンド」の意味を説明する

アプリのランキングは、社会全体の話題度を保証するものではありません。

アプリ内に、次の説明を表示します。

話題度は、このアプリが取得した公開情報をもとに、新しさ、反応数、増加速度、複数情報源での掲載状況から算出した独自指標です。社会全体の注目度を示すものではありません。

誤解を防ぐため、ランキングの計算範囲を明示します。


7.8 Android・iOS・Webで動作確認する

Flutterは複数のプラットフォームへ対応できますが、すべて同じ動作になるとは限りません。

最低限、次の機能を各環境で確認します。

共通確認
├─ Supabaseからデータを取得できる
├─ サムネイルを表示できる
├─ 情報源で絞り込める
├─ キーワードで検索できる
├─ 話題度順へ並べられる
├─ 新着順へ並べられる
├─ 元投稿を開ける
├─ ブックマークを保存できる
└─ エラー時に再読み込みできる

Androidで確認する

Androidのメインマニフェストを確認します。

app/android/app/src/main/AndroidManifest.xml

インターネット権限がない場合は、manifest要素の直下へ追加します。

<uses-permission
    android:name="android.permission.INTERNET" />

例は次のとおりです。

<manifest
    xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission
        android:name="android.permission.INTERNET" />

    <application
        android:label="SNSトレンド"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">

        <!-- Flutterの既存設定 -->

    </application>
</manifest>

実機またはエミュレーターで起動します。

flutter run \
  --dart-define-from-file=env/development.json

リリース用APKを作成します。

flutter build apk \
  --release \
  --dart-define-from-file=env/development.json

作成したAPKは、通常次の場所へ出力されます。

build/app/outputs/flutter-apk/app-release.apk

確認する内容は次のとおりです。

  • モバイル回線でも取得できる
  • 外部ブラウザを開ける
  • 戻る操作でアプリへ戻れる
  • ブックマークが再起動後も残る
  • 画像取得失敗時に画面が崩れない

iOSで確認する

iOSでは、XcodeとiOS Simulatorまたは実機を使用します。

flutter run \
  --dart-define-from-file=env/development.json

署名なしでビルド確認する場合は、次のコマンドを使用します。

flutter build ios \
  --release \
  --no-codesign \
  --dart-define-from-file=env/development.json

今回使用する通信先はHTTPSを前提にしています。

HTTPのRSSフィードやAPIを追加すると、iOSの通信制限へ影響する場合があります。

MVPでは、HTTPSの情報源だけを使用します。

確認する内容は次のとおりです。

  • Safariで元投稿を開ける
  • アプリへ戻れる
  • ブックマークを保存できる
  • サムネイルを表示できる
  • 画面上部や下部が端末の領域と重ならない
  • 小さい端末でも検索やフィルターを操作できる

Webで確認する

Web版を起動します。

flutter run -d chrome \
  --dart-define-from-file=env/development.json

リリースビルドを作成します。

flutter build web \
  --release \
  --dart-define-from-file=env/development.json

Webでは、Publishable keyやSupabase URLをブラウザから確認できます。

これは公開クライアント向けの値を使用しているため問題ありません。

ただし、データ保護はキーを隠すことではなく、RLSで行います。

Webで重要な設定
├─ Secret keyを含めない
├─ RLSを有効にする
├─ 読み取りだけを許可する
└─ collection_logsを公開しない

Webでは、元投稿が新しいタブで開く場合があります。

ブックマークは、ブラウザのローカル保存領域へ保存されます。

次の場合は、保存データが消える可能性があります。

  • ブラウザの保存データを削除した
  • プライベートブラウズを使用した
  • 別のブラウザを使用した
  • 別の端末を使用した

端末間同期が必要な場合は、ユーザー認証とSupabaseへのブックマーク保存を追加します。

画面幅を確認する

次の幅で表示を確認します。

スマートフォン
320px前後

一般的なスマートフォン
375pxから430px前後

タブレット
768px前後

デスクトップ
1200px以上

現在の一覧は1列表示です。

デスクトップでは余白が広がりすぎるため、最大幅を設定できます。

TrendPageの一覧部分を、次のように変更します。

Center(
  child: ConstrainedBox(
    constraints: const BoxConstraints(
      maxWidth: 760,
    ),
    child: visibleItems.when(
      // 既存の表示処理
    ),
  ),
)

スマートフォンでは画面幅いっぱいに表示し、タブレットやWebでは最大760pxに制限します。


アプリへ最終更新状態を表示する

利用者が、現在表示している情報の新しさを確認できるようにします。

最終収集日時Providerを作成する

次のファイルを作成します。

app/lib/features/trends/presentation/providers/latest_collection_time_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'trend_list_notifier.dart';

/// 役割:
/// 取得済みデータの中から最新の収集日時を提供する。
final latestCollectionTimeProvider =
    Provider<DateTime?>((ref) {
  final itemsState = ref.watch(
    rawTrendItemsProvider,
  );

  return itemsState.when(
    loading: () => null,
    error: (
      Object error,
      StackTrace stackTrace,
    ) {
      return null;
    },
    data: (items) {
      DateTime? latest;

      for (final item in items) {
        if (latest == null ||
            item.collectedAt.isAfter(latest)) {
          latest = item.collectedAt;
        }
      }

      return latest;
    },
  );
});

更新状態バナーを作成する

次のファイルを作成します。

app/lib/features/trends/presentation/widgets/collection_status_banner.dart
import 'package:flutter/material.dart';

import '../../../../core/formatters/relative_time_formatter.dart';

/// 役割:
/// 最終収集日時と更新遅延の警告を表示する。
final class CollectionStatusBanner
    extends StatelessWidget {
  const CollectionStatusBanner({
    required this.latestCollectedAt,
    super.key,
  });

  final DateTime? latestCollectedAt;

  @override
  Widget build(BuildContext context) {
    final now = DateTime.now().toUtc();

    final isDelayed = latestCollectedAt == null ||
        now.difference(
              latestCollectedAt!,
            ) >
            const Duration(hours: 8);

    final theme = Theme.of(context);

    return Container(
      width: double.infinity,
      padding: const EdgeInsets.symmetric(
        horizontal: 16,
        vertical: 10,
      ),
      color: isDelayed
          ? theme.colorScheme.errorContainer
          : theme.colorScheme.surfaceContainerHighest,
      child: Row(
        children: <Widget>[
          Icon(
            isDelayed
                ? Icons.warning_amber_rounded
                : Icons.sync,
            size: 18,
            color: isDelayed
                ? theme.colorScheme.onErrorContainer
                : null,
          ),
          const SizedBox(width: 8),
          Expanded(
            child: Text(
              latestCollectedAt == null
                  ? '最終更新時刻を確認できません。'
                  : isDelayed
                      ? '情報の更新が遅れています。'
                      : '最終更新:'
                          '${formatRelativeTime(latestCollectedAt)}',
              style: theme.textTheme.bodySmall?.copyWith(
                color: isDelayed
                    ? theme.colorScheme.onErrorContainer
                    : null,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

TrendPageでProviderを読み取ります。

final latestCollectedAt = ref.watch(
  latestCollectionTimeProvider,
);

検索欄の上などへ追加します。

CollectionStatusBanner(
  latestCollectedAt: latestCollectedAt,
),

これにより、GitHub Actionsが止まった場合でも、利用者は情報が古い可能性に気づけます。


まとめ

この教材では、複数のSNSとRSSから公開情報を収集し、独自の話題度を計算してFlutterアプリへ表示する仕組みを作りました。

最終的な構成は次のとおりです。

Mastodon
├─ トレンドタグ
└─ トレンド投稿

Bluesky
└─ キーワードに一致する公開投稿

RSS/Atom
└─ ニュース・ブログ・公式発表

YouTube
└─ 任意で新着動画を取得
          ↓
GitHub Actions
├─ 3時間ごとに実行
├─ 情報源ごとにエラーを分離
├─ HTTP 429へ対応
└─ 実行結果を記録
          ↓
Collector
├─ JSON・XMLを検証
├─ TrendItemへ変換
├─ 重複を除去
├─ 話題度を計算
└─ 削除状態を管理
          ↓
Supabase
├─ trend_items
├─ collection_logs
├─ RLS
└─ 古いデータの削除
          ↓
Flutter
├─ トレンド一覧
├─ 話題度順
├─ 新着順
├─ 情報源フィルター
├─ キーワード検索
├─ 外部リンク
├─ ブックマーク
└─ 最終更新状態

このアプリのMVPは、次の条件を満たしています。

複数の情報源からデータを収集できる

話題度順に並べられる

SNS別に絞り込める

キーワードで検索できる

元投稿をブラウザで開ける

一定間隔で自動収集できる

月額費用を発生させずに運用できる

ただし、「無料」は無制限を意味しません。

無料で運用を続けるには、次の管理が必要です。

無料運用の条件
├─ APIの取得回数を制限する
├─ GitHub Actionsの実行時間を確認する
├─ Supabaseの容量を確認する
├─ 古いデータを削除する
├─ 画像ファイルを複製しない
├─ 取得対象を増やしすぎない
└─ 各サービスの条件変更を確認する

また、このアプリが表示する話題度は、社会全体の注目度を保証するものではありません。

話題度
=
このアプリが取得できた公開情報の中での
相対的な注目度

収集対象、検索キーワード、取得間隔、話題度の配点を変更すると、ランキング結果も変わります。

公開後は、実際の結果を確認しながら次の値を調整します。

  • 検索キーワード
  • 取得間隔
  • 取得件数
  • 新しさの半減時間
  • いいね、コメント、共有の重み
  • 急上昇判定の比較期間
  • データの保存期間

これで、情報収集からFlutterでの表示、自動運用までを含むSNSトレンド収集アプリが完成しました。

FAQ

よくある質問

運用を確認してアプリを完成させるは医療関係者向けだけの内容ですか。
医療分野の例が含まれる場合もありますが、医療関係者だけに限定した内容ではありません。生成AI、AI活用、DX、業務改善、プロトタイプ開発など、一般的なAI学習の事例として読める内容です。
AI初心者でも読めますか。
はい。AIをこれから学ぶ方、数学が苦手な方、仕事でAIを使いたい方にも読み進めやすいように、教材の章と節の流れに沿って整理しています。
サムネイル画像は必ず表示されますか。
はい。教材にcoverUrlが設定されている場合はその画像を表示し、未設定の場合は代替サムネイル画像を表示します。
Flutterアプリケーション開発概論のほかの章も読めますか。
はい。教材トップから章立てを確認でき、前後の節へもページ下部のナビゲーションから移動できます。