TEXTBOOK SECTION / AI LEARNING

DBへ保存して自動収集する

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

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

OVERVIEW

この節で学べること

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

TABLE OF CONTENTS

目次

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
BlueskyAT URI
RSS/Atomguidid、元記事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_at
  • updated_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へ最新状態を保存できるようになりました。

次のページでは、保存された投稿の新しさ、いいね数、コメント数、共有数などを使い、アプリ独自の話題度を計算します。

FAQ

よくある質問

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