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
collectedとsavedが異なるのは、重複データを除去しているためです。
collected
└─ 各情報源から取得した合計件数
saved
└─ 同じIDを除去した後の保存件数
Supabaseの収集ログを確認する
Supabaseのcollection_logsテーブルを開きます。
次の情報が記録されていることを確認します。
| カラム | 確認内容 |
|---|---|
status | success、partial、failed |
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トレンド収集アプリが完成しました。