CONTENT
ここから
前ページでは、Mastodon、Bluesky、RSS、YouTubeから取得したデータを、共通のTrendItemへ変換しました。
このページでは、取得した情報をSupabaseへ保存します。
さらに、同じ投稿を何度も登録しない仕組みを作り、GitHub Actionsから3時間ごとに収集プログラムを実行します。
完成後の処理は、次のようになります。
GitHub Actionsが収集処理を開始
↓
Mastodon・Bluesky・RSS・YouTubeから取得
↓
TrendItemへ変換
↓
同じIDのデータを整理
↓
Supabaseへ追加または更新
↓
収集結果をログへ保存
このページでは、次の状態を完成条件とします。
trend_itemsテーブルへ投稿を保存できる- 同じ投稿が重複登録されない
- 反応数や話題度を更新できる
- 収集処理の成功・失敗を記録できる
- GitHub Actionsから手動実行できる
- 3時間ごとに自動実行できる
- Secret keyをソースコードへ書かない
4.1 投稿を保存するテーブルを作成する
最初に、SupabaseへTrendItemを保存するテーブルを作成します。
Supabaseの管理画面から直接テーブルを作ることもできますが、本教材ではSQLマイグレーションとして管理します。
マイグレーションを使うと、次の情報をGitで確認できます。
- どのテーブルを作成したか
- どのカラムを追加したか
- 制約をどのように設定したか
- RLSをどのように設定したか
- いつデータベース構造を変更したか
マイグレーションファイルを作成する
次のファイルを作成します。
supabase/migrations/202607240001_create_trend_items.sql
-- 役割:
-- SNS、RSS、YouTubeから収集した情報を保存するテーブルを作成する。
create table if not exists public.trend_items (
id text primary key,
source text not null
check (
source in (
'mastodon_tag',
'mastodon_status',
'bluesky',
'rss',
'youtube'
)
),
title text not null,
text text not null default '',
author_name text,
published_at timestamptz,
original_url text not null,
thumbnail_url text,
like_count integer
check (
like_count is null
or like_count >= 0
),
comment_count integer
check (
comment_count is null
or comment_count >= 0
),
share_count integer
check (
share_count is null
or share_count >= 0
),
trend_score double precision not null default 0,
collected_at timestamptz not null,
first_collected_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.trend_items is
'SNS、RSS、YouTubeから収集した公開情報を保存する。';
comment on column public.trend_items.id is
'情報源と投稿固有IDを組み合わせた一意の識別子。';
comment on column public.trend_items.source is
'mastodon_tag、mastodon_status、bluesky、rss、youtubeのいずれか。';
comment on column public.trend_items.published_at is
'元情報の公開日時。取得できない場合はnull。';
comment on column public.trend_items.collected_at is
'収集プログラムが最後に取得した日時。';
comment on column public.trend_items.first_collected_at is
'このアプリが初めて情報を確認した日時。';
comment on column public.trend_items.updated_at is
'データベース上で最後に更新した日時。';
カラムの役割
| カラム | 役割 |
|---|---|
id | 投稿や記事を一意に識別する |
source | 情報源の種類 |
title | 一覧表示用のタイトル |
text | 投稿本文または記事概要 |
author_name | 投稿者、配信元、チャンネル名 |
published_at | 元情報の公開日時 |
original_url | 元投稿や記事を開くURL |
thumbnail_url | 外部画像のURL |
like_count | いいね数 |
comment_count | コメントまたは返信数 |
share_count | 共有、リポスト、ブースト数 |
trend_score | アプリ独自の話題度 |
collected_at | 最後に収集した日時 |
first_collected_at | 初めて収集した日時 |
updated_at | データベース上の更新日時 |
取得できない数値はnullにする
RSSでは、通常、いいね数やコメント数を取得できません。
この場合、0ではなくnullを保存します。
like_count = 0
└─ いいね数を取得できており、実際に0件だった
like_count = null
└─ いいね数そのものを取得できなかった
この違いを残しておくことで、後から正しい条件で集計できます。
検索用インデックスを追加する
Flutterアプリでは、次の条件でデータを読み込みます。
- 話題度が高い順
- 公開日時が新しい順
- 情報源別
- キーワード検索
- 元URLによる確認
検索を補助するため、インデックスを作成します。
create index if not exists trend_items_trend_score_index
on public.trend_items (
trend_score desc
);
create index if not exists trend_items_published_at_index
on public.trend_items (
published_at desc nulls last
);
create index if not exists trend_items_source_index
on public.trend_items (
source
);
create index if not exists trend_items_collected_at_index
on public.trend_items (
collected_at desc
);
create index if not exists trend_items_original_url_index
on public.trend_items (
original_url
);
投稿数が少ないうちは、インデックスの有無による差は小さいかもしれません。
しかし、収集を続けるとデータが増えるため、よく使う並び替えや絞り込みにはインデックスを設定しておきます。
RLSを有効にする
Flutterアプリからは、投稿の読み取りだけを許可します。
投稿の追加、更新、削除は、Secret keyを持つ収集プログラムだけが行います。
alter table public.trend_items
enable row level security;
匿名利用者とログイン利用者へ、読み取りだけを許可します。
create policy "Public can read trend items"
on public.trend_items
for select
to anon, authenticated
using (true);
Flutterアプリ用のPublishable keyでは、次の操作だけが可能になります。
許可する
└─ trend_itemsの読み取り
許可しない
├─ trend_itemsへの追加
├─ trend_itemsの更新
└─ trend_itemsの削除
Secret keyを使うサーバー側の処理は、通常、RLSの制限を受けずにデータを操作できます。
そのため、Secret keyはFlutterアプリへ入れてはいけません。
4.2 SNSごとの投稿IDを管理する
重複登録を防ぐには、投稿ごとに変わらないIDが必要です。
今回のTrendItem.idには、情報源を表す接頭辞を付けています。
Mastodonタグ
mastodon_tag:mastodon.social:flutter
Mastodon投稿
mastodon_status:mastodon.social:123456789
Bluesky投稿
bluesky:at://did:plc:example/app.bsky.feed.post/abc123
RSS記事
rss:SHA-256で生成した文字列
YouTube動画
youtube:動画ID
情報源をIDへ含める理由
異なるSNSで、同じ文字列のIDが使われる可能性があります。
例えば、次の2件は別の投稿です。
Mastodonの投稿ID
12345
別のMastodonサーバーの投稿ID
12345
投稿IDだけを保存すると、同じデータと誤認する可能性があります。
そこで、次の情報を組み合わせます。
Mastodon投稿の一意ID
= 情報源
+ インスタンス名
+ 投稿ID
mastodon_status:mastodon.social:12345
これにより、別のインスタンスに同じ投稿IDが存在しても区別できます。
RSSには共通の投稿IDがない場合がある
RSSでは、記事ごとにguidが設定されていることがあります。
ただし、すべてのRSSで安定したguidが提供されるとは限りません。
そのため、第3ページでは次の優先順位でIDを生成しました。
RSSの記事ID
├─ guidがある
│ └─ guidからハッシュを生成
│
└─ guidがない
└─ 元記事URLからハッシュを生成
final entryId = guid ?? originalUrl;
final id = 'rss:${createHash(entryId)}';
URLをそのままIDにすることもできますが、URLが長い場合があります。
そのため、SHA-256で固定長の文字列へ変換しています。
IDは取得のたびに変えてはいけない
次の値をIDへ使用してはいけません。
- 現在日時
- 収集日時
- 一覧上の順位
- 毎回生成するランダムUUID
- 変化する反応数
- 変更される可能性があるタイトル
例えば、収集日時からIDを作ると、同じ投稿でも毎回違うIDになります。
1回目
bluesky:2026-07-24T00:00:00Z
2回目
bluesky:2026-07-24T03:00:00Z
この状態では、同じ投稿が別データとして登録されます。
IDには、情報源が持つ固有IDまたは安定したURLを使用します。
ID生成ルールをまとめる
| 情報源 | IDの材料 |
|---|---|
| Mastodonタグ | インスタンス名とタグ名 |
| Mastodon投稿 | インスタンス名と投稿ID |
| Bluesky | AT URI |
| RSS/Atom | guid、id、元記事URL |
| YouTube | 動画ID |
このIDをtrend_itemsテーブルの主キーとして使用します。
id text primary key
主キーへ同じIDを2回追加することはできません。
ただし、同じ投稿を再取得したときはエラーにせず、反応数や収集日時を更新したい場合があります。
そこで、保存時にはinsertではなくupsertを使用します。
4.3 同じ投稿の重複登録を防ぐ
重複は、主に2つの段階で発生します。
重複が発生する場所
├─ 1回の収集処理の中
│ └─ 複数キーワードで同じ投稿が取得される
│
└─ 複数回の収集処理の間
└─ 3時間前と同じ投稿が再取得される
両方の重複へ対応します。
1回の収集結果をID単位でまとめる
例えば、Blueskyで次のキーワードを検索するとします。
Flutter
AI
アプリ開発
一つの投稿に複数のキーワードが含まれていれば、同じ投稿が複数回取得されます。
Supabaseへ送信する前に、idをキーにして一つへまとめます。
次のファイルを作成します。
collector/lib/src/services/duplicate_detection_service.dart
import '../models/trend_item.dart';
/// 役割:
/// 同一の収集処理内に含まれる重複データをID単位で整理する。
final class DuplicateDetectionService {
const DuplicateDetectionService();
/// 役割:
/// 同じIDを持つTrendItemを一つへまとめる。
///
/// 入力:
/// 複数の情報源や検索キーワードから取得したTrendItem。
///
/// 出力:
/// IDが重複していないTrendItemの一覧。
List<TrendItem> removeDuplicateIds(
Iterable<TrendItem> items,
) {
final itemsById = <String, TrendItem>{};
for (final item in items) {
final currentItem = itemsById[item.id];
if (currentItem == null) {
itemsById[item.id] = item;
continue;
}
itemsById[item.id] = _selectNewerItem(
currentItem,
item,
);
}
return itemsById.values.toList(growable: false);
}
/// 役割:
/// 同じIDのデータから、より新しく取得された方を選択する。
///
/// 入力:
/// 現在保持しているデータと、新しく見つかったデータ。
///
/// 出力:
/// collectedAtが新しいTrendItem。
TrendItem _selectNewerItem(
TrendItem currentItem,
TrendItem newItem,
) {
if (newItem.collectedAt.isAfter(
currentItem.collectedAt,
)) {
return newItem;
}
return currentItem;
}
}
使用方法は次のとおりです。
const duplicateDetectionService =
DuplicateDetectionService();
final uniqueItems =
duplicateDetectionService.removeDuplicateIds(
allCollectedItems,
);
Supabaseではupsertを使用する
upsertは、データが存在しない場合は追加し、すでに存在する場合は更新する処理です。
Supabaseへ保存
↓
同じidが存在する?
├─ いいえ
│ └─ 新しい行を追加する
│
└─ はい
└─ 既存の行を更新する
例えば、最初の収集時点では次の状態だったとします。
like_count: 10
comment_count: 2
share_count: 3
3時間後に同じ投稿を取得すると、反応数が増えている可能性があります。
like_count: 35
comment_count: 6
share_count: 12
同じ投稿を新しい行として追加せず、既存の行を更新します。
Supabase接続設定を作成する
次のファイルを作成します。
collector/lib/src/config/collector_environment.dart
import 'dart:io';
import 'package:dotenv/dotenv.dart';
/// 役割:
/// 収集プログラムに必要な環境変数を保持する。
final class CollectorEnvironment {
const CollectorEnvironment({
required this.supabaseUrl,
required this.supabaseSecretKey,
required this.youtubeApiKey,
});
/// SupabaseプロジェクトのURL。
final String supabaseUrl;
/// サーバー側だけで使用するSupabase Secret key。
final String supabaseSecretKey;
/// YouTubeを使用しない場合はnull。
final String? youtubeApiKey;
/// 役割:
/// OS環境変数または.envから設定値を読み込む。
///
/// 入力:
/// なし。
///
/// 出力:
/// 検証済みのCollectorEnvironment。
static CollectorEnvironment load() {
final dotenv = DotEnv();
final envFile = File('.env');
if (envFile.existsSync()) {
dotenv.load();
}
final supabaseUrl = _requireValue(
name: 'SUPABASE_URL',
dotenv: dotenv,
);
final supabaseSecretKey = _requireValue(
name: 'SUPABASE_SECRET_KEY',
dotenv: dotenv,
);
final youtubeApiKey = _optionalValue(
name: 'YOUTUBE_API_KEY',
dotenv: dotenv,
);
return CollectorEnvironment(
supabaseUrl: supabaseUrl,
supabaseSecretKey: supabaseSecretKey,
youtubeApiKey: youtubeApiKey,
);
}
/// 役割:
/// 必須の環境変数を取得する。
///
/// 入力:
/// 環境変数名とDotEnv。
///
/// 出力:
/// 空ではない設定値。
static String _requireValue({
required String name,
required DotEnv dotenv,
}) {
final value = _optionalValue(
name: name,
dotenv: dotenv,
);
if (value == null) {
throw StateError(
'$name is not configured.',
);
}
return value;
}
/// 役割:
/// 任意の環境変数を取得する。
///
/// 入力:
/// 環境変数名とDotEnv。
///
/// 出力:
/// 設定値。存在しない場合はnull。
static String? _optionalValue({
required String name,
required DotEnv dotenv,
}) {
final platformValue = Platform.environment[name];
if (platformValue != null &&
platformValue.trim().isNotEmpty) {
return platformValue.trim();
}
final dotenvValue = dotenv[name];
if (dotenvValue != null &&
dotenvValue.trim().isNotEmpty) {
return dotenvValue.trim();
}
return null;
}
}
Supabaseクライアントを作成する
collector/lib/src/config/supabase_client_factory.dart
import 'package:supabase/supabase.dart';
import 'collector_environment.dart';
/// 役割:
/// 収集プログラム用のSupabaseClientを作成する。
abstract final class SupabaseClientFactory {
/// 役割:
/// Secret keyを使ったサーバー用クライアントを作成する。
///
/// 入力:
/// CollectorEnvironment。
///
/// 出力:
/// 初期化済みのSupabaseClient。
static SupabaseClient create(
CollectorEnvironment environment,
) {
return SupabaseClient(
environment.supabaseUrl,
environment.supabaseSecretKey,
);
}
}
TrendRepositoryを作成する
次のファイルを作成します。
collector/lib/src/repositories/trend_repository.dart
import 'package:supabase/supabase.dart';
import '../models/trend_item.dart';
/// 役割:
/// Supabaseのtrend_itemsテーブルを操作する。
final class TrendRepository {
const TrendRepository({
required SupabaseClient supabase,
}) : _supabase = supabase;
final SupabaseClient _supabase;
/// 役割:
/// TrendItemを追加または更新する。
///
/// 入力:
/// 重複を除去したTrendItemの一覧。
///
/// 出力:
/// なし。
Future<void> upsertItems(
List<TrendItem> items,
) async {
if (items.isEmpty) {
return;
}
const batchSize = 100;
for (
var startIndex = 0;
startIndex < items.length;
startIndex += batchSize
) {
final endIndex =
startIndex + batchSize > items.length
? items.length
: startIndex + batchSize;
final batch = items.sublist(
startIndex,
endIndex,
);
final updatedAt = DateTime.now()
.toUtc()
.toIso8601String();
final rows = batch.map((item) {
return <String, Object?>{
...item.toJson(),
'updated_at': updatedAt,
};
}).toList(growable: false);
await _supabase
.from('trend_items')
.upsert(
rows,
onConflict: 'id',
);
}
}
/// 役割:
/// 指定日時より古いデータを削除する。
///
/// 入力:
/// 削除対象となる最終収集日時。
///
/// 出力:
/// なし。
Future<void> deleteItemsCollectedBefore(
DateTime threshold,
) async {
await _supabase
.from('trend_items')
.delete()
.lt(
'collected_at',
threshold.toUtc().toIso8601String(),
);
}
}
100件ずつに分けて保存しているのは、1回のリクエストへ大量のデータを詰め込みすぎないためです。
MVPの取得件数が少ない場合でも、後からキーワードやRSSを増やしやすくなります。
first_collected_atは更新しない
upsert時のデータには、first_collected_atを含めていません。
新規登録
└─ データベースのdefault now()が設定される
既存データの更新
└─ 元のfirst_collected_atが維持される
一方、次の値は再取得のたびに更新します。
- 本文
- タイトル
- 反応数
- サムネイルURL
- 話題度
collected_atupdated_atこれにより、初めて見つけた日時を残したまま、最新の状態を保存できます。
4.4 GitHub Actionsの収集処理を作る
次に、GitHub Actionsから実行できるようにcollect.dartを完成させます。
処理の流れは次のとおりです。
収集開始
↓
環境変数を確認
↓
各情報源から取得
↓
取得結果を一つへまとめる
↓
同じIDを除去
↓
Supabaseへupsert
↓
実行ログを保存
↓
終了
収集処理の実行結果を定義する
次のファイルを作成します。
collector/lib/src/models/collection_run_summary.dart
/// 役割:
/// 1回の収集処理全体の結果を保持する。
final class CollectionRunSummary {
const CollectionRunSummary({
required this.startedAt,
required this.finishedAt,
required this.collectedCount,
required this.savedCount,
required this.successfulSourceCount,
required this.failedSourceCount,
required this.errors,
});
final DateTime startedAt;
final DateTime finishedAt;
/// 重複除去前に取得した件数。
final int collectedCount;
/// 重複除去後にSupabaseへ保存した件数。
final int savedCount;
final int successfulSourceCount;
final int failedSourceCount;
/// 情報源名とエラー内容。
final Map<String, String> errors;
/// 役割:
/// 収集処理の状態を返す。
///
/// 出力:
/// success、partial、failedのいずれか。
String get status {
if (failedSourceCount == 0) {
return 'success';
}
if (successfulSourceCount > 0) {
return 'partial';
}
return 'failed';
}
}
collect.dartを更新する
第3ページで作成した収集処理へ、Supabase保存を追加します。
collector/bin/collect.dart
import 'dart:io';
import 'package:sns_trend_collector/sns_trend_collector.dart';
/// 役割:
/// 複数の情報源からデータを収集し、Supabaseへ保存する。
///
/// 入力:
/// 環境変数とソースコード内の収集対象設定。
///
/// 出力:
/// 成功時は終了コード0。
/// 全情報源の収集または保存に失敗した場合は終了コード1。
Future<void> main() async {
final startedAt = DateTime.now().toUtc();
final environment = CollectorEnvironment.load();
final supabase = SupabaseClientFactory.create(
environment,
);
final httpClient = ResilientHttpClient();
const collectionService = CollectionService();
const duplicateDetectionService =
DuplicateDetectionService();
final trendRepository = TrendRepository(
supabase: supabase,
);
final logRepository = CollectionLogRepository(
supabase: supabase,
);
try {
final mastodonClient = MastodonClient(
instanceBaseUri: Uri.parse(
'https://mastodon.social',
),
httpClient: httpClient,
);
final blueskyClient = BlueskyClient(
httpClient: httpClient,
);
final rssClient = RssClient(
httpClient: httpClient,
);
final results = <SourceCollectionResult>[];
results.add(
await collectionService.collectSafely(
sourceName: 'mastodon_tags',
collector: () {
return mastodonClient.fetchTrendingTags(
limit: 10,
);
},
),
);
results.add(
await collectionService.collectSafely(
sourceName: 'mastodon_statuses',
collector: () {
return mastodonClient
.fetchTrendingStatuses(
limit: 20,
);
},
),
);
const searchKeywords = <String>[
'Flutter',
'AI',
'医療',
];
for (final keyword in searchKeywords) {
results.add(
await collectionService.collectSafely(
sourceName: 'bluesky:$keyword',
collector: () {
return blueskyClient.searchPosts(
query: keyword,
limit: 20,
language: 'ja',
);
},
),
);
}
const feedUrls = <String>[
'https://example.com/feed.xml',
];
for (final feedUrl in feedUrls) {
results.add(
await collectionService.collectSafely(
sourceName: 'rss:$feedUrl',
collector: () {
return rssClient.fetchFeed(
feedUri: Uri.parse(feedUrl),
limit: 20,
);
},
),
);
}
final youtubeApiKey =
environment.youtubeApiKey;
if (youtubeApiKey != null) {
final youtubeClient = YouTubeClient(
apiKey: youtubeApiKey,
httpClient: httpClient,
);
for (final keyword in searchKeywords) {
results.add(
await collectionService.collectSafely(
sourceName: 'youtube:$keyword',
collector: () {
return youtubeClient.searchVideos(
query: keyword,
limit: 10,
publishedAfter: DateTime.now()
.toUtc()
.subtract(
const Duration(days: 2),
),
);
},
),
);
}
}
final allItems = <TrendItem>[];
final errors = <String, String>{};
var successfulSourceCount = 0;
var failedSourceCount = 0;
for (final result in results) {
if (result.isSuccess) {
successfulSourceCount++;
allItems.addAll(result.items);
print(
'${result.sourceName}: '
'${result.items.length} items collected.',
);
} else {
failedSourceCount++;
errors[result.sourceName] =
result.errorMessage ??
'Unknown collection error.';
print(
'${result.sourceName}: '
'collection failed.',
);
}
}
final uniqueItems =
duplicateDetectionService.removeDuplicateIds(
allItems,
);
await trendRepository.upsertItems(
uniqueItems,
);
final finishedAt = DateTime.now().toUtc();
final summary = CollectionRunSummary(
startedAt: startedAt,
finishedAt: finishedAt,
collectedCount: allItems.length,
savedCount: uniqueItems.length,
successfulSourceCount:
successfulSourceCount,
failedSourceCount: failedSourceCount,
errors: errors,
);
await logRepository.insert(summary);
print(
'Collection completed. '
'status=${summary.status}, '
'collected=${summary.collectedCount}, '
'saved=${summary.savedCount}, '
'failedSources=${summary.failedSourceCount}',
);
if (summary.status == 'failed') {
exitCode = 1;
}
} on Object catch (error, stackTrace) {
final finishedAt = DateTime.now().toUtc();
print('Collection process failed: $error');
print(stackTrace);
try {
await logRepository.insertFatalError(
startedAt: startedAt,
finishedAt: finishedAt,
errorMessage: error.toString(),
);
} on Object catch (logError) {
print(
'Failed to save collection log: '
'$logError',
);
}
exitCode = 1;
} finally {
httpClient.close();
}
}
feedUrlsには、実際に利用するRSSまたはAtomのURLを設定してください。
const feedUrls = <String>[
'https://example.com/feed.xml',
];
このままではexample.comの仮URLなので、実際の収集には使用できません。
4.5 3時間ごとに自動実行する
GitHub Actionsのワークフローを作成します。
次のファイルを作成します。
.github/workflows/collect-trends.yml
name: Collect SNS Trends
on:
workflow_dispatch:
schedule:
- cron: '17 */3 * * *'
permissions:
contents: read
concurrency:
group: collect-sns-trends
cancel-in-progress: false
jobs:
collect:
name: Collect and save trends
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: collector
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }}
YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Dart
uses: dart-lang/setup-dart@v1
with:
sdk: stable
- name: Install dependencies
run: dart pub get
- name: Run collector
run: dart run bin/collect.dart
workflow_dispatch
workflow_dispatch:
この設定を追加すると、GitHubの管理画面から手動で収集処理を実行できます。
定期実行を有効にする前に、まず手動実行で動作を確認します。
schedule
schedule:
- cron: '17 */3 * * *'
この設定は、3時間ごとの17分に実行する指定です。
00:17
03:17
06:17
09:17
12:17
15:17
18:17
21:17
GitHub ActionsのcronはUTCを基準に扱われます。
日本時間では、UTCへ9時間を加えます。
UTC 00:17
↓
日本時間 09:17
今回の処理は3時間間隔であるため、開始時刻がどこであっても、1日に8回実行されます。
なぜ毎時0分を避けるのか
多くの定期処理は、次のように毎時0分へ設定されます。
cron: '0 */3 * * *'
同じ時刻に実行要求が集中する可能性があるため、本教材では17分にずらしています。
正確に17分に開始されることを保証するものではありません。
このアプリでは、数分程度の遅れがあっても問題にならない設計とします。
concurrency
concurrency:
group: collect-sns-trends
cancel-in-progress: false
前の収集処理が終了していない状態で、次の処理が始まることを防ぐための設定です。
前回の収集が実行中
↓
次の実行時刻になる
↓
前回の処理が終わるまで待機
同じ処理が同時にSupabaseへ保存すると、不要な競合やAPIアクセスの増加につながります。
timeout-minutes
timeout-minutes: 10
通信障害などによって処理が終了しない場合でも、10分で停止します。
3時間ごとの軽量な収集処理で10分以上かかる場合は、次の問題を確認します。
- 取得対象が多すぎないか
- 再試行回数が多すぎないか
- RSSの応答が遅くないか
- 同じAPIへ大量アクセスしていないか
- Supabaseへの保存件数が多すぎないか
自動実行前にローカルで確認する
GitHub ActionsへPushする前に、ローカルで実行します。
cd collector
dart format .
dart analyze
dart test
dart run bin/collect.dart
すべて正常に終了したら、Gitへ保存します。
git add .
git commit -m "feat: save collected trends with scheduled workflow"
git push
4.6 GitHub Secretsへ認証情報を登録する
GitHub ActionsからSupabaseへ保存するには、Repository Secretsへ認証情報を登録します。
登録する値は次のとおりです。
必須
├─ SUPABASE_URL
└─ SUPABASE_SECRET_KEY
任意
└─ YOUTUBE_API_KEY
GitHubの画面から登録する
対象リポジトリを開き、次の順番で移動します。
Settings
↓
Secrets and variables
↓
Actions
↓
New repository secret
次のSecretを一つずつ登録します。
SUPABASE_URL
Name
SUPABASE_URL
Secret
SupabaseプロジェクトのURL
SUPABASE_SECRET_KEY
Name
SUPABASE_SECRET_KEY
Secret
SupabaseのSecret key
古いSupabaseプロジェクトでSecret keyが表示されない場合は、サーバー用のservice_role keyを使用する場合があります。
いずれの場合も、FlutterアプリやGit管理下のファイルへ書いてはいけません。
YOUTUBE_API_KEY
YouTubeを利用する場合だけ登録します。
Name
YOUTUBE_API_KEY
Secret
YouTube Data API用のAPIキー
YouTubeを利用しない場合は、登録しなくても構いません。
収集プログラムは、APIキーが存在しない場合にYouTubeの処理をスキップします。
Secretをログへ表示しない
次のようなログは出力してはいけません。
print(environment.supabaseSecretKey);
print(environment.youtubeApiKey);
GitHub Actionsには一部のSecretを伏せ字にする仕組みがありますが、それだけに依存してはいけません。
ログには、設定の有無だけを表示します。
print(
environment.youtubeApiKey == null
? 'YouTube collection is disabled.'
: 'YouTube collection is enabled.',
);
Secretが登録されているか確認する
Secretの内容は、登録後にGitHub上で再表示できません。
確認できるのは、名前が存在していることだけです。
値を間違えた場合は、Secretを更新します。
Actions secrets and variables
↓
登録済みSecretを選択
↓
Update secret
GitHub Actionsを手動実行する
Secretを登録したら、GitHub上で次の順番に進みます。
Actions
↓
Collect SNS Trends
↓
Run workflow
↓
Run workflow
実行中のワークフローを開き、次のステップが成功しているか確認します。
Checkout repository
Set up Dart
Install dependencies
Run collector
成功すると、ログへ次のような内容が表示されます。
mastodon_tags: 10 items collected.
mastodon_statuses: 20 items collected.
bluesky:Flutter: 20 items collected.
rss:example: 15 items collected.
Collection completed.
status=success
collected=65
saved=58
failedSources=0
Secretの値そのものは表示されません。
4.7 収集成功・失敗ログを保存する
定期実行では、成功したかどうかを後から確認できる必要があります。
GitHub Actionsの実行履歴だけでも確認できますが、古い履歴が消えたり、アプリ側から状態を確認しにくかったりします。
そこで、Supabaseにも収集ログを保存します。
collection_logsテーブルを作成する
次のファイルを作成します。
supabase/migrations/202607240002_create_collection_logs.sql
-- 役割:
-- GitHub Actionsによる収集処理の実行結果を保存する。
create table if not exists public.collection_logs (
id bigint generated by default as identity primary key,
status text not null
check (
status in (
'success',
'partial',
'failed'
)
),
started_at timestamptz not null,
finished_at timestamptz not null,
collected_count integer not null default 0
check (
collected_count >= 0
),
saved_count integer not null default 0
check (
saved_count >= 0
),
successful_source_count integer not null default 0
check (
successful_source_count >= 0
),
failed_source_count integer not null default 0
check (
failed_source_count >= 0
),
errors jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
comment on table public.collection_logs is
'定期収集処理の成功、部分成功、失敗を記録する。';
ログの状態
statusには、次の3種類を保存します。
success
└─ すべての情報源で収集できた
partial
└─ 一部の情報源で失敗したが、ほかは成功した
failed
└─ すべて失敗した、または保存処理が失敗した
例えば、YouTubeだけが失敗し、Mastodon、Bluesky、RSSが成功した場合はpartialです。
Mastodon → 成功
Bluesky → 成功
RSS → 成功
YouTube → 失敗
status = partial
収集ログは公開しない
collection_logsには、エラー内容や内部構成に関する情報が含まれる可能性があります。
Flutterアプリの一般利用者へ公開する必要はありません。
RLSだけを有効にし、公開読み取りポリシーは作成しません。
alter table public.collection_logs
enable row level security;
これにより、Publishable keyを使うFlutterアプリからは、原則として読み取れません。
Secret keyを使用する収集プログラムは、ログを追加できます。
将来、管理者専用画面からログを確認する場合は、認証済み管理者だけが読めるポリシーを別途設計します。
CollectionLogRepositoryを作成する
次のファイルを作成します。
collector/lib/src/repositories/collection_log_repository.dart
import 'package:supabase/supabase.dart';
import '../models/collection_run_summary.dart';
/// 役割:
/// 収集処理の実行結果をSupabaseへ保存する。
final class CollectionLogRepository {
const CollectionLogRepository({
required SupabaseClient supabase,
}) : _supabase = supabase;
final SupabaseClient _supabase;
/// 役割:
/// 通常の収集結果をcollection_logsへ保存する。
///
/// 入力:
/// CollectionRunSummary。
///
/// 出力:
/// なし。
Future<void> insert(
CollectionRunSummary summary,
) async {
await _supabase
.from('collection_logs')
.insert(
<String, Object?>{
'status': summary.status,
'started_at': summary.startedAt
.toUtc()
.toIso8601String(),
'finished_at': summary.finishedAt
.toUtc()
.toIso8601String(),
'collected_count':
summary.collectedCount,
'saved_count': summary.savedCount,
'successful_source_count':
summary.successfulSourceCount,
'failed_source_count':
summary.failedSourceCount,
'errors': summary.errors,
},
);
}
/// 役割:
/// 収集全体が予期せず停止した場合のログを保存する。
///
/// 入力:
/// 開始日時、終了日時、エラーメッセージ。
///
/// 出力:
/// なし。
Future<void> insertFatalError({
required DateTime startedAt,
required DateTime finishedAt,
required String errorMessage,
}) async {
await _supabase
.from('collection_logs')
.insert(
<String, Object?>{
'status': 'failed',
'started_at':
startedAt.toUtc().toIso8601String(),
'finished_at':
finishedAt.toUtc().toIso8601String(),
'collected_count': 0,
'saved_count': 0,
'successful_source_count': 0,
'failed_source_count': 1,
'errors': <String, String>{
'fatal': errorMessage,
},
},
);
}
}
エラー内容へ秘密情報を含めない
外部APIのエラー本文には、リクエスト情報が含まれる場合があります。
ログへ保存する前に、次の情報が含まれていないことを確認します。
- Supabase Secret key
- YouTube APIキー
- データベースパスワード
- Authorizationヘッダー
- セッショントークン
- 個人情報
URLのクエリパラメータへAPIキーが含まれている場合は、URL全体をエラーへ保存しないよう注意します。
例えば、YouTubeのリクエストURLにはAPIキーが含まれる場合があります。
保存してはいけない
https://www.googleapis.com/youtube/v3/search?key=秘密情報
エラーログには、ホスト名、ステータスコード、処理名など、原因確認に必要な範囲だけを保存します。
古いログを削除する
収集ログは、3時間ごとに1件ずつ増えます。
1日
8件
30日
約240件
1年
約2,920件
文字列の長いエラーを大量に保存しなければ、すぐに容量を圧迫する件数ではありません。
それでも無期限には残さず、MVPでは90日程度を目安に削除できます。
次のメソッドを追加します。
/// 役割:
/// 指定日時より古い収集ログを削除する。
///
/// 入力:
/// 削除基準日時。
///
/// 出力:
/// なし。
Future<void> deleteLogsBefore(
DateTime threshold,
) async {
await _supabase
.from('collection_logs')
.delete()
.lt(
'created_at',
threshold.toUtc().toIso8601String(),
);
}
毎回削除する必要はありません。
例えば、収集処理の最後に、90日より古いログを削除します。
await logRepository.deleteLogsBefore(
DateTime.now().toUtc().subtract(
const Duration(days: 90),
),
);
同様に、古い投稿を無期限に保存しない場合は、保存期間を決めて削除します。
例えば180日より古く、最近再取得されていない投稿を削除します。
await trendRepository.deleteItemsCollectedBefore(
DateTime.now().toUtc().subtract(
const Duration(days: 180),
),
);
ただし、過去のトレンド推移を分析する予定がある場合は、削除方針を別途検討します。
ライブラリの公開ファイルを更新する
このページで追加したクラスを、パッケージ外から読み込めるようにします。
次のファイルを更新します。
collector/lib/sns_trend_collector.dart
export 'src/clients/bluesky/bluesky_client.dart';
export 'src/clients/mastodon/mastodon_client.dart';
export 'src/clients/rss/rss_client.dart';
export 'src/clients/youtube/youtube_client.dart';
export 'src/config/collector_environment.dart';
export 'src/config/supabase_client_factory.dart';
export 'src/models/collection_run_summary.dart';
export 'src/models/trend_item.dart';
export 'src/models/trend_source.dart';
export 'src/network/resilient_http_client.dart';
export 'src/repositories/collection_log_repository.dart';
export 'src/repositories/trend_repository.dart';
export 'src/services/collection_service.dart';
export 'src/services/duplicate_detection_service.dart';
マイグレーションをSupabaseへ適用する
SQLファイルを作成しただけでは、Supabase上のデータベースには反映されません。
Supabase CLIを利用する場合は、プロジェクトをリンクしてマイグレーションを適用します。
supabase login
supabase link \
--project-ref プロジェクト参照ID
supabase db push
初めて実行する前に、適用されるSQLの内容を確認してください。
Supabase CLIを使用しない場合は、SQL Editorから作成したSQLを順番に実行します。
適用する順番は次のとおりです。
1. 202607240001_create_trend_items.sql
2. 202607240002_create_collection_logs.sql
適用後、SupabaseのTable Editorで次のテーブルが存在することを確認します。
trend_items
collection_logs
ローカルで保存処理を確認する
collector/.envへ接続情報を設定します。
SUPABASE_URL=SupabaseプロジェクトのURL
SUPABASE_SECRET_KEY=SupabaseのSecret key
YOUTUBE_API_KEY=
YouTubeを使用しない場合は、YOUTUBE_API_KEYを空にします。
.envはGitへ追加しません。
収集処理を実行します。
cd collector
dart format .
dart analyze
dart test
dart run bin/collect.dart
実行後、Supabaseで次の内容を確認します。
trend_items
- 投稿や記事が保存されている
idに情報源の接頭辞がある- 同じIDが複数存在しない
- 取得できない反応数が
nullになっている collected_atが更新されているfirst_collected_atが維持されている
collection_logs
- 実行結果が1行追加されている
statusが保存されている- 取得件数と保存件数が保存されている
- 一部失敗した場合は
errorsに記録されている
同じ処理をもう一度実行する
同じ収集処理を2回実行します。
dart run bin/collect.dart
dart run bin/collect.dart
trend_itemsの行数が単純に2倍にならず、同じIDのデータが更新されれば、upsertが機能しています。
第4ページの完了チェック
次の項目を確認してください。
第4ページのまとめ
このページでは、収集したTrendItemをSupabaseへ保存し、GitHub Actionsから定期実行する仕組みを作りました。
完成した処理は次のとおりです。
GitHub Actions
├─ 手動実行
└─ 3時間ごとの自動実行
↓
Collector
├─ Mastodonから取得
├─ Blueskyから取得
├─ RSS/Atomから取得
└─ 必要に応じてYouTubeから取得
↓
DuplicateDetectionService
└─ 同じIDを一つへまとめる
↓
TrendRepository
└─ Supabaseへupsert
↓
CollectionLogRepository
└─ 成功・部分成功・失敗を記録
重複登録を防ぐ仕組みは、次の2段階です。
収集プログラム
└─ 同じ実行内の重複をID単位で除去
Supabase
└─ 主キーとupsertで複数回の収集による重複を防止
また、認証情報は次のように分離しました。
Flutterアプリ
├─ SUPABASE_URL
└─ SUPABASE_PUBLISHABLE_KEY
GitHub Actions
├─ SUPABASE_URL
├─ SUPABASE_SECRET_KEY
└─ YOUTUBE_API_KEY
これで、利用者がアプリを開いていない間も情報を収集し、Supabaseへ最新状態を保存できるようになりました。
次のページでは、保存された投稿の新しさ、いいね数、コメント数、共有数などを使い、アプリ独自の話題度を計算します。