TEXTBOOK SECTION / AI LEARNING

話題度を計算してトレンドを判定する

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

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

OVERVIEW

この節で学べること

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

TABLE OF CONTENTS

目次

CONTENT

ここから

第4ページでは、収集した投稿や記事をSupabaseへ保存し、GitHub Actionsから3時間ごとに更新できるようにしました。

このページでは、保存前のTrendItemに対して、アプリ独自の話題度を計算します。

単純にいいね数だけを比較すると、古い投稿や利用者数の多いSNSが上位を占める可能性があります。そのため、次の要素を組み合わせます。

話題度
├─ 投稿の新しさ
├─ 現在の反応数
├─ 前回取得時からの増加量
├─ 複数情報源での掲載
└─ キーワードの増加傾向

最終的な話題度は、0点から100点の範囲で計算します。

話題度スコア
├─ 新しさ                最大25点
├─ 現在の反応数          最大25点
├─ 反応数の増加速度      最大25点
├─ 複数情報源ボーナス    最大15点
└─ キーワード上昇度      最大10点
                         ────────
                         最大100点

この点数は、社会全体の注目度を客観的に証明するものではありません。

このアプリが収集した情報の中で、相対的に注目度が高い情報を並べるための独自指標です。


5.1 単純な反応数だけでは判定できない理由

SNSには、サービスごとに利用者数や反応の付き方の違いがあります。

例えば、次の2件があるとします。

投稿A
├─ 公開から2時間
├─ いいね数:100
└─ 共有数:30

投稿B
├─ 公開から30日
├─ いいね数:1,000
└─ 共有数:100

単純にいいね数だけを比較すると、投稿Bが上位になります。

しかし、「現在伸びている投稿」を探したい場合は、公開から2時間で反応が増えている投稿Aの方が重要かもしれません。

情報源ごとに取得できる値が異なる

取得できる反応数にも違いがあります。

情報源いいねコメント共有公開日時
Mastodon投稿取得可能取得可能取得可能取得可能
Bluesky投稿取得可能取得可能取得可能取得可能
Mastodonタグ取得不可取得不可取得不可履歴日
RSS/Atom通常取得不可通常取得不可通常取得不可取得できる場合が多い
YouTube検索取得不可取得不可取得不可取得可能

RSSの記事は、SNS投稿のような反応数を持っていません。

そのため、反応数が取得できない記事を常に0点にすると、RSSがランキングへ表示されにくくなります。

RSSでは、次の要素を重視します。

  • 公開されたばかりか
  • 同じテーマの記事が複数存在するか
  • SNSでも同じキーワードが使われているか
  • 複数の配信元が同じ話題を扱っているか

古い投稿は反応数が増えやすい

公開されてから時間が経過した投稿は、反応を集める時間も長くなります。

公開から1時間
└─ いいね数:50

公開から30日
└─ いいね数:500

1時間で50件と、30日で500件では、反応が増える速度が異なります。

そのため、次の2つを分けて評価します。

現在の反応数
└─ 現時点でどれだけ反応されているか

反応数の増加速度
└─ 前回の取得後にどれだけ増えたか

大きな数値をそのまま使わない

反応数をそのまま点数にすると、非常に大きな差が生まれます。

投稿A:反応数10
投稿B:反応数100
投稿C:反応数10,000

この状態で単純加算すると、投稿Cだけが極端に高い点数になります。

そこで、対数を使って数値差を圧縮します。

元の反応数
10
100
10,000

対数変換後
差は残るが、極端な開きは小さくなる

本教材では、次の方針で話題度を計算します。

反応数が増える
    ↓
スコアも増える

ただし
    ↓
反応数が100倍になっても
スコアは100倍にならない

5.2 投稿の新しさをスコアへ反映する

最初に、投稿や記事の公開日時から新しさを計算します。

本教材では、公開から24時間経過するごとに、新しさの点数が半分になる計算を使用します。

公開直後
約25点

24時間後
約12.5点

48時間後
約6.25点

72時間後
約3.1点

これにより、古い投稿が反応数だけで上位に残り続けることを防ぎます。

公開日時がない場合

RSSによっては、公開日時を取得できない場合があります。

公開日時が不明な情報にcollectedAtを代入すると、古い記事を新着と誤認する可能性があります。

そのため、公開日時が取得できない場合、新しさの点数は0点とします。

publishedAtがある
└─ 経過時間から計算する

publishedAtがない
└─ 新しさは0点

新しさを計算するクラスを作成する

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

collector/lib/src/services/freshness_score_calculator.dart
import 'dart:math' as math;

/// 役割:
/// 投稿の公開日時から新しさのスコアを計算する。
final class FreshnessScoreCalculator {
  const FreshnessScoreCalculator({
    this.maximumScore = 25,
    this.halfLife = const Duration(hours: 24),
  });

  /// 公開直後に付与する最大点。
  final double maximumScore;

  /// スコアが半分になるまでの時間。
  final Duration halfLife;

  /// 役割:
  /// 公開日時と現在日時から新しさを計算する。
  ///
  /// 入力:
  /// 公開日時と現在日時。
  ///
  /// 出力:
  /// 0点以上、maximumScore以下の新しさスコア。
  double calculate({
    required DateTime? publishedAt,
    required DateTime now,
  }) {
    if (publishedAt == null) {
      return 0;
    }

    final normalizedPublishedAt =
        publishedAt.toUtc();
    final normalizedNow = now.toUtc();

    final difference = normalizedNow.difference(
      normalizedPublishedAt,
    );

    final ageInHours = difference.isNegative
        ? 0.0
        : difference.inMinutes / 60;

    final halfLifeInHours =
        halfLife.inMinutes / 60;

    if (halfLifeInHours <= 0) {
      throw StateError(
        'halfLife must be greater than zero.',
      );
    }

    final decay = math.pow(
      0.5,
      ageInHours / halfLifeInHours,
    );

    final score = maximumScore * decay.toDouble();

    return score.clamp(
      0,
      maximumScore,
    );
  }
}

未来日時を受け取った場合

外部サービスの時刻設定やデータ不整合によって、現在より未来の公開日時が返る可能性があります。

今回のコードでは、未来日時を公開直後として扱います。

final ageInHours = difference.isNegative
    ? 0.0
    : difference.inMinutes / 60;

ただし、頻繁に未来日時が発生する場合は、APIレスポンス、タイムゾーン、端末時刻を確認する必要があります。

新しさの計算例

/// 役割:
/// 新しさスコアの計算例を表示する。
///
/// 入力:
/// なし。
///
/// 出力:
/// 計算結果を標準出力へ表示する。
void printFreshnessExamples() {
  const calculator = FreshnessScoreCalculator();

  final now = DateTime.utc(
    2026,
    7,
    24,
    12,
  );

  final newPostScore = calculator.calculate(
    publishedAt: now.subtract(
      const Duration(hours: 2),
    ),
    now: now,
  );

  final oldPostScore = calculator.calculate(
    publishedAt: now.subtract(
      const Duration(days: 7),
    ),
    now: now,
  );

  print('2時間前: $newPostScore');
  print('7日前: $oldPostScore');
}

公開から時間が経過するほど、点数が滑らかに下がります。


5.3 いいね・コメント・共有を重み付けする

次に、投稿の反応数を点数へ変換します。

本教材では、次の重みを使用します。

いいね
1件 × 1

コメント・返信
1件 × 2

共有・リポスト・ブースト
1件 × 3

共有は、ほかの利用者へ情報を広げる操作です。

そのため、いいねより大きい重みを設定します。

基本反応値
= いいね数
+ コメント数 × 2
+ 共有数 × 3

この重みは、アプリの設計上決める値です。すべてのSNSに共通する客観的な基準ではありません。

運用結果を確認しながら調整します。

TrendItemへスコア更新用メソッドを追加する

TrendItemは変更不可のクラスとして定義しています。

計算後のtrendScoreを設定するため、新しいインスタンスを返すメソッドを追加します。

collector/lib/src/models/trend_item.dart

TrendItemクラス内へ、次のメソッドを追加します。

/// 役割:
/// 話題度だけを変更した新しいTrendItemを作成する。
///
/// 入力:
/// 新しく設定する話題度。
///
/// 出力:
/// ほかの値を維持した新しいTrendItem。
TrendItem withTrendScore(double value) {
  return TrendItem(
    id: id,
    source: source,
    title: title,
    text: text,
    authorName: authorName,
    publishedAt: publishedAt,
    originalUrl: originalUrl,
    thumbnailUrl: thumbnailUrl,
    likeCount: likeCount,
    commentCount: commentCount,
    shareCount: shareCount,
    trendScore: value,
    collectedAt: collectedAt,
  );
}

現在の反応数を計算する

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

collector/lib/src/services/engagement_score_calculator.dart
import 'dart:math' as math;

import '../models/trend_item.dart';
import '../models/trend_source.dart';

/// 役割:
/// 現在の反応数からエンゲージメントスコアを計算する。
final class EngagementScoreCalculator {
  const EngagementScoreCalculator({
    this.maximumScore = 25,
    this.referenceEngagement = 1000,
  });

  /// 反応数に対して付与する最大点。
  final double maximumScore;

  /// 最大点へ近づく基準となる反応値。
  ///
  /// 実データを確認しながら調整する設計値。
  final double referenceEngagement;

  /// 役割:
  /// TrendItemの反応数を0点からmaximumScoreへ変換する。
  ///
  /// 入力:
  /// 現在のTrendItem。
  ///
  /// 出力:
  /// 反応数に基づくスコア。
  double calculate(TrendItem item) {
    final rawEngagement = calculateRawEngagement(
      item,
    );

    if (rawEngagement <= 0) {
      return 0;
    }

    final denominator = math.log(
      1 + referenceEngagement,
    );

    if (denominator <= 0) {
      throw StateError(
        'referenceEngagement must be greater than zero.',
      );
    }

    final normalized =
        math.log(1 + rawEngagement) /
        denominator;

    final score = normalized * maximumScore;

    return score.clamp(
      0,
      maximumScore,
    );
  }

  /// 役割:
  /// いいね、コメント、共有から重み付き反応値を計算する。
  ///
  /// 入力:
  /// 現在のTrendItem。
  ///
  /// 出力:
  /// 0以上の重み付き反応値。
  double calculateRawEngagement(
    TrendItem item,
  ) {
    if (item.source == TrendSource.mastodonTag) {
      return math.max(
        0,
        item.trendScore,
      );
    }

    final likeCount = item.likeCount ?? 0;
    final commentCount = item.commentCount ?? 0;
    final shareCount = item.shareCount ?? 0;

    return math.max(
      0,
      likeCount +
          commentCount * 2 +
          shareCount * 3,
    ).toDouble();
  }
}

Mastodonタグには、いいね数や共有数がありません。

第3ページでは、タグの利用回数を仮のtrendScoreへ設定しているため、最終スコアを計算する前に、その値を情報源固有の反応値として使用します。

nullと0の違い

計算時には、取得できない値を0として加算しています。

final likeCount = item.likeCount ?? 0;

これは、「反応数が0件だった」とデータを書き換えることではありません。

データベースへ保存する値は、引き続きnullを維持します。

保存データ
like_count = null

スコア計算時
加算できる反応値がないため0として扱う

前回取得時からの増加速度を計算する

現在の反応数だけではなく、前回からどれだけ増えたかも評価します。

前回取得時
いいね:100
コメント:10
共有:5

今回取得時
いいね:160
コメント:18
共有:20

重み付き反応値の増加量を、経過時間で割ります。

1時間あたりの増加量
= 今回の重み付き反応値
- 前回の重み付き反応値
÷ 経過時間

前回の状態を表すモデルを作成する

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

collector/lib/src/models/previous_trend_state.dart
import '../utils/json_reader.dart';

/// 役割:
/// Supabaseへ保存されている前回取得時の状態を保持する。
final class PreviousTrendState {
  const PreviousTrendState({
    required this.id,
    required this.likeCount,
    required this.commentCount,
    required this.shareCount,
    required this.collectedAt,
  });

  final String id;
  final int? likeCount;
  final int? commentCount;
  final int? shareCount;
  final DateTime collectedAt;

  /// 役割:
  /// Supabaseの行データからPreviousTrendStateを作成する。
  ///
  /// 入力:
  /// Supabaseから取得したJSON。
  ///
  /// 出力:
  /// 検証済みのPreviousTrendState。
  factory PreviousTrendState.fromJson(
    JsonMap json,
  ) {
    final collectedAt = parseIsoDateTime(
      optionalString(
        json,
        'collected_at',
      ),
    );

    if (collectedAt == null) {
      throw FormatException(
        'Previous trend state has no valid collected_at.',
      );
    }

    return PreviousTrendState(
      id: requireString(
        json,
        'id',
        context: 'PreviousTrendState',
      ),
      likeCount: optionalInt(
        json,
        'like_count',
      ),
      commentCount: optionalInt(
        json,
        'comment_count',
      ),
      shareCount: optionalInt(
        json,
        'share_count',
      ),
      collectedAt: collectedAt,
    );
  }
}

前回の状態を取得する

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

/// 役割:
/// 指定したIDの前回保存状態を取得する。
///
/// 入力:
/// 現在収集したTrendItemのID一覧。
///
/// 出力:
/// IDをキーにしたPreviousTrendState。
Future<Map<String, PreviousTrendState>>
    findPreviousStates(
  List<String> ids,
) async {
  if (ids.isEmpty) {
    return const <String, PreviousTrendState>{};
  }

  const batchSize = 100;
  final states =
      <String, PreviousTrendState>{};

  for (
    var startIndex = 0;
    startIndex < ids.length;
    startIndex += batchSize
  ) {
    final endIndex =
        startIndex + batchSize > ids.length
            ? ids.length
            : startIndex + batchSize;

    final batch = ids.sublist(
      startIndex,
      endIndex,
    );

    final response = await _supabase
        .from('trend_items')
        .select(
          'id, like_count, comment_count, '
          'share_count, collected_at',
        )
        .inFilter(
          'id',
          batch,
        );

    for (final row in response) {
      final json = Map<String, Object?>.from(
        row,
      );

      final state =
          PreviousTrendState.fromJson(json);

      states[state.id] = state;
    }
  }

  return states;
}

次のインポートも追加します。

import '../models/previous_trend_state.dart';

増加速度を計算するクラスを作成する

collector/lib/src/services/growth_score_calculator.dart
import 'dart:math' as math;

import '../models/previous_trend_state.dart';
import '../models/trend_item.dart';
import '../models/trend_source.dart';

/// 役割:
/// 前回取得時からの反応数の増加速度を計算する。
final class GrowthScoreCalculator {
  const GrowthScoreCalculator({
    this.maximumScore = 25,
    this.referenceGrowthPerHour = 50,
  });

  final double maximumScore;

  /// 最大点へ近づく基準となる1時間あたりの増加量。
  final double referenceGrowthPerHour;

  /// 役割:
  /// 現在と前回の状態から増加速度スコアを計算する。
  ///
  /// 入力:
  /// 現在のTrendItemと前回取得時の状態。
  ///
  /// 出力:
  /// 0点以上、maximumScore以下のスコア。
  double calculate({
    required TrendItem currentItem,
    required PreviousTrendState? previousState,
  }) {
    if (previousState == null) {
      return 0;
    }

    if (!_supportsGrowthScore(currentItem)) {
      return 0;
    }

    final elapsed = currentItem.collectedAt
        .toUtc()
        .difference(
          previousState.collectedAt.toUtc(),
        );

    final elapsedHours =
        elapsed.inMinutes / 60;

    if (elapsedHours <= 0) {
      return 0;
    }

    final currentValue = _calculateRawValue(
      likeCount: currentItem.likeCount,
      commentCount: currentItem.commentCount,
      shareCount: currentItem.shareCount,
    );

    final previousValue = _calculateRawValue(
      likeCount: previousState.likeCount,
      commentCount:
          previousState.commentCount,
      shareCount: previousState.shareCount,
    );

    final increase = currentValue - previousValue;

    if (increase <= 0) {
      return 0;
    }

    final increasePerHour =
        increase / elapsedHours;

    final denominator = math.log(
      1 + referenceGrowthPerHour,
    );

    if (denominator <= 0) {
      throw StateError(
        'referenceGrowthPerHour must be greater than zero.',
      );
    }

    final normalized =
        math.log(1 + increasePerHour) /
        denominator;

    final score = normalized * maximumScore;

    return score.clamp(
      0,
      maximumScore,
    );
  }

  /// 役割:
  /// 増加速度を計算できる情報源か確認する。
  ///
  /// 入力:
  /// TrendItem。
  ///
  /// 出力:
  /// 比較可能な反応数を持つ場合はtrue。
  bool _supportsGrowthScore(
    TrendItem item,
  ) {
    return item.source ==
            TrendSource.mastodonStatus ||
        item.source == TrendSource.bluesky;
  }

  /// 役割:
  /// 反応数から重み付き値を計算する。
  ///
  /// 入力:
  /// いいね、コメント、共有の件数。
  ///
  /// 出力:
  /// 重み付けした反応値。
  double _calculateRawValue({
    required int? likeCount,
    required int? commentCount,
    required int? shareCount,
  }) {
    return (
      (likeCount ?? 0) +
      (commentCount ?? 0) * 2 +
      (shareCount ?? 0) * 3
    ).toDouble();
  }
}

前回の状態が存在しない新規投稿には、増加速度を付与しません。

初回取得
└─ 増加量を比較できないため0点

2回目以降
└─ 前回との差から計算する

初回取得を急上昇と決めつけないことで、反応数が少ない新規投稿が過剰に評価されることを防ぎます。


5.4 同じURLや似た投稿をまとめる

同じニュースが、複数のSNSやRSSで取り上げられることがあります。

RSS
└─ 新製品発表の記事

Mastodon
└─ 同じ記事URLを紹介する投稿

Bluesky
└─ 同じ記事URLを紹介する投稿

複数の情報源で同じテーマが扱われている場合、話題度を加点します。

本教材では、次の順番で関連性を判定します。

1. 元URLが同じか
2. 追跡用パラメータを除くと同じURLか
3. 登録キーワードが共通しているか

追跡用パラメータを除去する

同じ記事でも、URLへ分析用パラメータが付いている場合があります。

https://example.com/news/123?utm_source=mastodon

https://example.com/news/123?utm_source=bluesky

この2つは、実質的には同じページです。

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

collector/lib/src/utils/url_utils.dart
/// 追跡用途として除去するクエリパラメータ。
const Set<String> trackingParameterNames =
    <String>{
  'fbclid',
  'gclid',
  'mc_cid',
  'mc_eid',
  'ref',
};

/// 役割:
/// 追跡用パラメータを除去し、比較用URLを作成する。
///
/// 入力:
/// 元のURL。
///
/// 出力:
/// 比較可能なURL。URLが不正な場合は元文字列。
String canonicalizeUrl(String value) {
  final uri = Uri.tryParse(value.trim());

  if (uri == null || uri.host.isEmpty) {
    return value.trim();
  }

  final filteredParameters =
      <String, String>{};

  final sortedKeys =
      uri.queryParameters.keys.toList()
        ..sort();

  for (final key in sortedKeys) {
    final normalizedKey = key.toLowerCase();

    if (normalizedKey.startsWith('utm_')) {
      continue;
    }

    if (
      trackingParameterNames.contains(
        normalizedKey,
      )
    ) {
      continue;
    }

    final parameterValue =
        uri.queryParameters[key];

    if (parameterValue != null) {
      filteredParameters[key] = parameterValue;
    }
  }

  var normalizedPath = uri.path;

  if (
    normalizedPath.length > 1 &&
    normalizedPath.endsWith('/')
  ) {
    normalizedPath = normalizedPath.substring(
      0,
      normalizedPath.length - 1,
    );
  }

  return uri
      .replace(
        scheme: uri.scheme.toLowerCase(),
        host: uri.host.toLowerCase(),
        path: normalizedPath,
        queryParameters:
            filteredParameters.isEmpty
                ? null
                : filteredParameters,
        fragment: '',
      )
      .toString();
}

キーワードによるテーマ判定

日本語の文章から単語を完全に自動抽出するには、形態素解析などが必要です。

無料で簡潔なMVPを作るため、この段階ではあらかじめ登録したキーワードを使用します。

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

本文またはタイトルに同じキーワードが含まれている場合、同じテーマ候補として扱います。

ただし、「AI」のように広いキーワードだけでは、内容が異なる投稿まで同じグループになる可能性があります。

そのため、URLが一致する場合を優先し、キーワードだけの一致には小さい加点を使用します。

複数情報源ボーナスを計算する

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

collector/lib/src/services/cross_source_score_calculator.dart
import 'dart:math' as math;

import '../models/trend_item.dart';
import '../utils/url_utils.dart';

/// 役割:
/// 同じURLまたはキーワードを扱う情報源数から加点する。
final class CrossSourceScoreCalculator {
  const CrossSourceScoreCalculator({
    required this.monitoredKeywords,
    this.maximumScore = 15,
  });

  final List<String> monitoredKeywords;
  final double maximumScore;

  /// 役割:
  /// 各TrendItemに付与する複数情報源ボーナスを計算する。
  ///
  /// 入力:
  /// 1回の収集処理で取得したTrendItem。
  ///
  /// 出力:
  /// TrendItemのIDとボーナス点のMap。
  Map<String, double> calculateBonuses(
    List<TrendItem> items,
  ) {
    final urlGroups =
        <String, List<TrendItem>>{};
    final keywordGroups =
        <String, List<TrendItem>>{};

    for (final item in items) {
      final canonicalUrl = canonicalizeUrl(
        item.originalUrl,
      );

      urlGroups
          .putIfAbsent(
            canonicalUrl,
            () => <TrendItem>[],
          )
          .add(item);

      for (final keyword
          in _findMatchedKeywords(item)) {
        keywordGroups
            .putIfAbsent(
              keyword,
              () => <TrendItem>[],
            )
            .add(item);
      }
    }

    final bonuses = <String, double>{
      for (final item in items) item.id: 0,
    };

    for (final group in urlGroups.values) {
      final score = _calculateGroupScore(
        items: group,
        pointsPerAdditionalSource: 7.5,
      );

      _applyMaximumBonus(
        bonuses: bonuses,
        items: group,
        score: score,
      );
    }

    for (final group in keywordGroups.values) {
      final score = _calculateGroupScore(
        items: group,
        pointsPerAdditionalSource: 3,
      );

      _applyMaximumBonus(
        bonuses: bonuses,
        items: group,
        score: score,
      );
    }

    return bonuses;
  }

  /// 役割:
  /// 投稿に含まれる監視キーワードを取得する。
  ///
  /// 入力:
  /// TrendItem。
  ///
  /// 出力:
  /// 一致したキーワード。
  List<String> _findMatchedKeywords(
    TrendItem item,
  ) {
    final target =
        '${item.title}\n${item.text}'
            .toLowerCase();

    return monitoredKeywords
        .where(
          (keyword) => target.contains(
            keyword.toLowerCase(),
          ),
        )
        .toList(growable: false);
  }

  /// 役割:
  /// グループ内の異なる情報源数から加点する。
  ///
  /// 入力:
  /// 同じテーマに属する投稿と加点係数。
  ///
  /// 出力:
  /// 最大点を超えないボーナス点。
  double _calculateGroupScore({
    required List<TrendItem> items,
    required double pointsPerAdditionalSource,
  }) {
    final sourceCount = items
        .map((item) => item.source)
        .toSet()
        .length;

    if (sourceCount <= 1) {
      return 0;
    }

    return math.min(
      maximumScore,
      (sourceCount - 1) *
          pointsPerAdditionalSource,
    );
  }

  /// 役割:
  /// 各投稿へ現在より大きいボーナスだけを設定する。
  ///
  /// 入力:
  /// ボーナスMap、対象投稿、計算点。
  ///
  /// 出力:
  /// なし。
  void _applyMaximumBonus({
    required Map<String, double> bonuses,
    required List<TrendItem> items,
    required double score,
  }) {
    for (final item in items) {
      final currentScore =
          bonuses[item.id] ?? 0;

      bonuses[item.id] = math.max(
        currentScore,
        score,
      );
    }
  }
}

同じURLが複数の情報源に存在する場合は、大きめに加点します。

キーワードだけが一致する場合は、関連性が弱い可能性があるため、小さめに加点します。

同じURL
└─ 強い関連性

同じキーワードのみ
└─ 弱い関連性

5.5 急上昇キーワードを抽出する

「急上昇」と判断するには、現在の件数だけでなく、過去の件数との比較が必要です。

現在6時間
「Flutter」を含む新着情報:20件

その前の6時間
「Flutter」を含む新着情報:5件

この場合、Flutterに関する新着情報が増加していると判断できます。

一方、次の状態では急上昇とはいえません。

現在6時間
「AI」を含む新着情報:50件

その前の6時間
「AI」を含む新着情報:48件

件数は多いものの、前の時間帯から大きく増えていません。

MVPでの急上昇キーワードの定義

本教材では、次の条件で計算します。

直近6時間に初めて収集した情報
                 ↓
キーワードごとに件数を集計
                 ↓
その前の6時間と比較
                 ↓
増加率と現在件数から0〜10点を計算

first_collected_atを使用するため、ここで計測するのは厳密な投稿数の増加ではありません。

このアプリが新しく発見した情報件数の増加傾向です。

同じ投稿の再取得回数は含めません。

キーワード集計用モデルを作成する

collector/lib/src/models/trend_text_record.dart
import 'trend_source.dart';

/// 役割:
/// キーワード集計に必要な最小限のデータを保持する。
final class TrendTextRecord {
  const TrendTextRecord({
    required this.title,
    required this.text,
    required this.source,
    required this.firstCollectedAt,
  });

  final String title;
  final String text;
  final TrendSource source;
  final DateTime firstCollectedAt;
}

指定期間のデータを取得する

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

/// 役割:
/// 指定日時以降に初めて収集した情報を取得する。
///
/// 入力:
/// 検索開始日時。
///
/// 出力:
/// キーワード集計用のTrendTextRecord。
Future<List<TrendTextRecord>>
    findFirstCollectedAfter(
  DateTime start,
) async {
  final response = await _supabase
      .from('trend_items')
      .select(
        'title, text, source, first_collected_at',
      )
      .gte(
        'first_collected_at',
        start.toUtc().toIso8601String(),
      );

  final records = <TrendTextRecord>[];

  for (final row in response) {
    final json = Map<String, Object?>.from(
      row,
    );

    final sourceValue = requireString(
      json,
      'source',
      context: 'TrendTextRecord',
    );

    final source = TrendSource.values
        .where(
          (value) =>
              value.databaseValue == sourceValue,
        )
        .firstOrNull;

    final firstCollectedAt = parseIsoDateTime(
      optionalString(
        json,
        'first_collected_at',
      ),
    );

    if (
      source == null ||
      firstCollectedAt == null
    ) {
      continue;
    }

    records.add(
      TrendTextRecord(
        title: optionalString(
              json,
              'title',
            ) ??
            '',
        text: optionalString(
              json,
              'text',
            ) ??
            '',
        source: source,
        firstCollectedAt: firstCollectedAt,
      ),
    );
  }

  return records;
}

次のインポートを追加します。

import '../models/trend_text_record.dart';
import '../utils/json_reader.dart';

利用しているDart環境でfirstOrNullが使用できない場合は、次の補助関数へ置き換えます。

/// 役割:
/// データベース用文字列からTrendSourceを取得する。
///
/// 入力:
/// データベースに保存されたsource。
///
/// 出力:
/// 一致するTrendSource。存在しない場合はnull。
TrendSource? findTrendSource(
  String databaseValue,
) {
  for (final source in TrendSource.values) {
    if (source.databaseValue == databaseValue) {
      return source;
    }
  }

  return null;
}

キーワードの上昇度を計算する

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

collector/lib/src/services/keyword_momentum_service.dart
import 'dart:math' as math;

import '../models/trend_item.dart';
import '../models/trend_text_record.dart';

/// 役割:
/// 現在と過去の新着件数を比較し、キーワード上昇度を計算する。
final class KeywordMomentumService {
  const KeywordMomentumService({
    required this.monitoredKeywords,
    this.maximumScore = 10,
  });

  final List<String> monitoredKeywords;
  final double maximumScore;

  /// 役割:
  /// 投稿ごとのキーワード上昇ボーナスを計算する。
  ///
  /// 入力:
  /// 今回取得した投稿、過去12時間のデータ、現在日時。
  ///
  /// 出力:
  /// TrendItemのIDとキーワード上昇点のMap。
  Map<String, double> calculateBonuses({
    required List<TrendItem> currentItems,
    required List<TrendTextRecord> history,
    required DateTime now,
    Duration window = const Duration(hours: 6),
  }) {
    final recentStart = now.toUtc().subtract(
      window,
    );

    final previousStart = recentStart.subtract(
      window,
    );

    final recentCounts = <String, int>{};
    final previousCounts = <String, int>{};

    for (final keyword in monitoredKeywords) {
      recentCounts[keyword] = 0;
      previousCounts[keyword] = 0;
    }

    for (final record in history) {
      final matchedKeywords = _findKeywords(
        title: record.title,
        text: record.text,
      );

      for (final keyword in matchedKeywords) {
        if (
          !record.firstCollectedAt.isBefore(
            recentStart,
          )
        ) {
          recentCounts[keyword] =
              (recentCounts[keyword] ?? 0) + 1;
          continue;
        }

        if (
          !record.firstCollectedAt.isBefore(
            previousStart,
          )
        ) {
          previousCounts[keyword] =
              (previousCounts[keyword] ?? 0) + 1;
        }
      }
    }

    final keywordScores = <String, double>{};

    for (final keyword in monitoredKeywords) {
      final recentCount =
          recentCounts[keyword] ?? 0;
      final previousCount =
          previousCounts[keyword] ?? 0;

      keywordScores[keyword] =
          _calculateKeywordScore(
        recentCount: recentCount,
        previousCount: previousCount,
      );
    }

    final itemBonuses = <String, double>{};

    for (final item in currentItems) {
      final matchedKeywords = _findKeywords(
        title: item.title,
        text: item.text,
      );

      var highestScore = 0.0;

      for (final keyword in matchedKeywords) {
        highestScore = math.max(
          highestScore,
          keywordScores[keyword] ?? 0,
        );
      }

      itemBonuses[item.id] = highestScore;
    }

    return itemBonuses;
  }

  /// 役割:
  /// タイトルと本文に含まれる監視キーワードを取得する。
  ///
  /// 入力:
  /// タイトルと本文。
  ///
  /// 出力:
  /// 一致した監視キーワード。
  List<String> _findKeywords({
    required String title,
    required String text,
  }) {
    final target =
        '$title\n$text'.toLowerCase();

    return monitoredKeywords
        .where(
          (keyword) => target.contains(
            keyword.toLowerCase(),
          ),
        )
        .toList(growable: false);
  }

  /// 役割:
  /// 現在件数と前期間件数から上昇度を計算する。
  ///
  /// 入力:
  /// 直近期間と前期間の件数。
  ///
  /// 出力:
  /// 0点以上、maximumScore以下の点数。
  double _calculateKeywordScore({
    required int recentCount,
    required int previousCount,
  }) {
    if (recentCount < 2) {
      return 0;
    }

    final ratio =
        (recentCount + 1) /
        (previousCount + 1);

    final ratioIncrease = math.max(
      0,
      ratio - 1,
    );

    final ratioScore = math.min(
      6,
      ratioIncrease * 3,
    );

    final volumeScore = math.min(
      4,
      recentCount.toDouble(),
    );

    return math.min(
      maximumScore,
      ratioScore + volumeScore,
    );
  }
}

件数が1件だけの場合は、偶然の可能性が高いため、急上昇点を付与しません。

if (recentCount < 2) {
  return 0;
}

キーワードの限界

部分一致検索では、意図しない一致が起こる可能性があります。

例えば、英字の短いキーワードは別の単語の一部へ一致する場合があります。

監視キーワード
AI

別の単語内に偶然含まれる可能性

MVPでは、次の対策を行います。

  • 短すぎるキーワードを避ける
  • 表記違いを明示的に登録する
  • 実際の検索結果を確認する
  • 誤一致が多いキーワードを削除する
  • 日本語と英語を必要に応じて分ける

高度な自動キーワード抽出は、発展機能として扱います。


5.6 トレンドランキングを作成する

ここまでに、次の5種類の点数を計算できるようになりました。

新しさ
最大25点

現在の反応数
最大25点

反応数の増加速度
最大25点

複数情報源ボーナス
最大15点

キーワード上昇度
最大10点

これらを合計して、最終的なtrendScoreを計算します。

点数の内訳を保持する

話題度の合計点だけでは、なぜ上位になったのか判断できません。

そこで、計算中は点数の内訳も保持します。

collector/lib/src/models/trend_score_breakdown.dart
/// 役割:
/// 話題度を構成する各点数を保持する。
final class TrendScoreBreakdown {
  const TrendScoreBreakdown({
    required this.freshness,
    required this.engagement,
    required this.growth,
    required this.crossSource,
    required this.keywordMomentum,
  });

  final double freshness;
  final double engagement;
  final double growth;
  final double crossSource;
  final double keywordMomentum;

  /// 役割:
  /// 各項目を合計して0点から100点へ制限する。
  ///
  /// 入力:
  /// なし。
  ///
  /// 出力:
  /// 最終的な話題度。
  double get total {
    final value = freshness +
        engagement +
        growth +
        crossSource +
        keywordMomentum;

    return value.clamp(
      0,
      100,
    );
  }
}

計算結果を保持する

collector/lib/src/models/scored_trend_item.dart
import 'trend_item.dart';
import 'trend_score_breakdown.dart';

/// 役割:
/// 点数を設定したTrendItemと内訳を保持する。
final class ScoredTrendItem {
  const ScoredTrendItem({
    required this.item,
    required this.breakdown,
  });

  final TrendItem item;
  final TrendScoreBreakdown breakdown;
}

話題度計算サービスを作成する

collector/lib/src/services/trend_score_service.dart
import '../models/previous_trend_state.dart';
import '../models/scored_trend_item.dart';
import '../models/trend_item.dart';
import '../models/trend_score_breakdown.dart';
import 'engagement_score_calculator.dart';
import 'freshness_score_calculator.dart';
import 'growth_score_calculator.dart';

/// 役割:
/// 各評価項目を合計して最終的な話題度を計算する。
final class TrendScoreService {
  const TrendScoreService({
    this.freshnessCalculator =
        const FreshnessScoreCalculator(),
    this.engagementCalculator =
        const EngagementScoreCalculator(),
    this.growthCalculator =
        const GrowthScoreCalculator(),
  });

  final FreshnessScoreCalculator
      freshnessCalculator;

  final EngagementScoreCalculator
      engagementCalculator;

  final GrowthScoreCalculator
      growthCalculator;

  /// 役割:
  /// 複数のTrendItemへ最終的な話題度を設定する。
  ///
  /// 入力:
  /// 現在の投稿、前回状態、複数情報源点、
  /// キーワード上昇点、現在日時。
  ///
  /// 出力:
  /// 点数設定済みの投稿と点数内訳。
  List<ScoredTrendItem> calculate({
    required List<TrendItem> items,
    required Map<String, PreviousTrendState>
        previousStates,
    required Map<String, double>
        crossSourceBonuses,
    required Map<String, double>
        keywordMomentumBonuses,
    required DateTime now,
  }) {
    return items.map((item) {
      final breakdown = TrendScoreBreakdown(
        freshness:
            freshnessCalculator.calculate(
          publishedAt: item.publishedAt,
          now: now,
        ),
        engagement:
            engagementCalculator.calculate(
          item,
        ),
        growth: growthCalculator.calculate(
          currentItem: item,
          previousState:
              previousStates[item.id],
        ),
        crossSource:
            crossSourceBonuses[item.id] ?? 0,
        keywordMomentum:
            keywordMomentumBonuses[item.id] ??
                0,
      );

      return ScoredTrendItem(
        item: item.withTrendScore(
          breakdown.total,
        ),
        breakdown: breakdown,
      );
    }).toList(growable: false);
  }
}

ランキングを並べ替える

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

collector/lib/src/services/trend_ranking_service.dart
import '../models/scored_trend_item.dart';

/// 役割:
/// 話題度と公開日時を使ってランキング順へ並べる。
final class TrendRankingService {
  const TrendRankingService();

  /// 役割:
  /// 話題度の高い順へ並べ替える。
  ///
  /// 入力:
  /// 話題度計算済みの投稿。
  ///
  /// 出力:
  /// ランキング順の新しいList。
  List<ScoredTrendItem> sort(
    List<ScoredTrendItem> items,
  ) {
    final sorted = List<ScoredTrendItem>.from(
      items,
    );

    sorted.sort((left, right) {
      final scoreComparison =
          right.item.trendScore.compareTo(
        left.item.trendScore,
      );

      if (scoreComparison != 0) {
        return scoreComparison;
      }

      final dateComparison =
          _comparePublishedAt(
        left.item.publishedAt,
        right.item.publishedAt,
      );

      if (dateComparison != 0) {
        return dateComparison;
      }

      return right.item.collectedAt.compareTo(
        left.item.collectedAt,
      );
    });

    return sorted;
  }

  /// 役割:
  /// 公開日時を新しい順へ比較する。
  ///
  /// 入力:
  /// 左右の公開日時。
  ///
  /// 出力:
  /// 並び替え用の比較結果。
  int _comparePublishedAt(
    DateTime? left,
    DateTime? right,
  ) {
    if (left == null && right == null) {
      return 0;
    }

    if (left == null) {
      return 1;
    }

    if (right == null) {
      return -1;
    }

    return right.compareTo(left);
  }
}

並び順は次の優先順位です。

1. 話題度が高い
2. 公開日時が新しい
3. 収集日時が新しい

5.7 収集処理へ話題度計算を組み込む

第4ページのcollect.dartでは、重複除去後すぐにSupabaseへ保存していました。

この処理を次の順番へ変更します。

重複を除去する
       ↓
前回の状態を取得する
       ↓
過去12時間の情報を取得する
       ↓
複数情報源ボーナスを計算する
       ↓
キーワード上昇度を計算する
       ↓
最終的な話題度を計算する
       ↓
ランキング順へ並べる
       ↓
Supabaseへ保存する

監視キーワードを定義する

collect.dart内へ監視キーワードを定義します。

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

BlueskyやYouTubeの検索キーワードと共通化できます。

const searchKeywords = monitoredKeywords;

スコア計算サービスを初期化する

main関数内へ追加します。

final crossSourceScoreCalculator =
    CrossSourceScoreCalculator(
  monitoredKeywords: monitoredKeywords,
);

final keywordMomentumService =
    KeywordMomentumService(
  monitoredKeywords: monitoredKeywords,
);

const trendScoreService =
    TrendScoreService();

const trendRankingService =
    TrendRankingService();

保存前の処理を置き換える

第4ページの次の処理を探します。

final uniqueItems =
    duplicateDetectionService.removeDuplicateIds(
  allItems,
);

await trendRepository.upsertItems(
  uniqueItems,
);

次の内容へ置き換えます。

final uniqueItems =
    duplicateDetectionService.removeDuplicateIds(
  allItems,
);

final previousStates =
    await trendRepository.findPreviousStates(
  uniqueItems
      .map((item) => item.id)
      .toList(growable: false),
);

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

final keywordHistory =
    await trendRepository.findFirstCollectedAfter(
  now.subtract(
    const Duration(hours: 12),
  ),
);

final crossSourceBonuses =
    crossSourceScoreCalculator.calculateBonuses(
  uniqueItems,
);

final keywordMomentumBonuses =
    keywordMomentumService.calculateBonuses(
  currentItems: uniqueItems,
  history: keywordHistory,
  now: now,
);

final scoredItems = trendScoreService.calculate(
  items: uniqueItems,
  previousStates: previousStates,
  crossSourceBonuses: crossSourceBonuses,
  keywordMomentumBonuses:
      keywordMomentumBonuses,
  now: now,
);

final rankedItems =
    trendRankingService.sort(
  scoredItems,
);

final itemsToSave = rankedItems
    .map((result) => result.item)
    .toList(growable: false);

await trendRepository.upsertItems(
  itemsToSave,
);

上位投稿の内訳をログへ表示する

計算結果を確認するため、上位5件だけ内訳を表示します。

/// 役割:
/// 上位投稿の話題度内訳をログへ表示する。
///
/// 入力:
/// ランキング済みの投稿。
///
/// 出力:
/// なし。
void printTopScores(
  List<ScoredTrendItem> rankedItems,
) {
  final topItems = rankedItems.take(5);

  for (var index = 0;
      index < topItems.length;
      index++) {
    final result = topItems.elementAt(index);
    final breakdown = result.breakdown;

    print(
      '${index + 1}. '
      '${result.item.title} '
      'total=${result.item.trendScore.toStringAsFixed(2)} '
      'freshness=${breakdown.freshness.toStringAsFixed(2)} '
      'engagement=${breakdown.engagement.toStringAsFixed(2)} '
      'growth=${breakdown.growth.toStringAsFixed(2)} '
      'crossSource=${breakdown.crossSource.toStringAsFixed(2)} '
      'keyword=${breakdown.keywordMomentum.toStringAsFixed(2)}',
    );
  }
}

保存前に呼び出します。

printTopScores(rankedItems);

await trendRepository.upsertItems(
  itemsToSave,
);

ログには、次のように表示されます。

1. Flutterの新機能が発表されました
total=82.40
freshness=23.10
engagement=18.30
growth=20.00
crossSource=15.00
keyword=6.00

これにより、どの要素によって上位になったかを確認できます。

保存件数を修正する

CollectionRunSummarysavedCountには、点数計算後の件数を設定します。

final summary = CollectionRunSummary(
  startedAt: startedAt,
  finishedAt: finishedAt,
  collectedCount: allItems.length,
  savedCount: itemsToSave.length,
  successfulSourceCount:
      successfulSourceCount,
  failedSourceCount: failedSourceCount,
  errors: errors,
);

ライブラリの公開ファイルを更新する

次のファイルを更新します。

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/previous_trend_state.dart';
export 'src/models/scored_trend_item.dart';
export 'src/models/trend_item.dart';
export 'src/models/trend_score_breakdown.dart';
export 'src/models/trend_source.dart';
export 'src/models/trend_text_record.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/cross_source_score_calculator.dart';
export 'src/services/duplicate_detection_service.dart';
export 'src/services/engagement_score_calculator.dart';
export 'src/services/freshness_score_calculator.dart';
export 'src/services/growth_score_calculator.dart';
export 'src/services/keyword_momentum_service.dart';
export 'src/services/trend_ranking_service.dart';
export 'src/services/trend_score_service.dart';

話題度計算をテストする

話題度の計算は、アプリのランキング結果へ直接影響します。

最低限、次の条件をテストします。

新しい投稿は古い投稿より新しさが高い

反応数が多い投稿は反応点が高い

反応数が減少した場合は増加点を付けない

前回データがない場合は増加点を付けない

同じテーマが1つのSNSだけなら複数情報源点を付けない

同じURLが複数SNSにあれば加点する

公開日時が不明な投稿は新しさを0点にする

新しさのテスト

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

collector/test/freshness_score_calculator_test.dart
import 'package:sns_trend_collector/sns_trend_collector.dart';
import 'package:test/test.dart';

/// 役割:
/// FreshnessScoreCalculatorの動作を検証する。
void main() {
  const calculator =
      FreshnessScoreCalculator();

  final now = DateTime.utc(
    2026,
    7,
    24,
    12,
  );

  test(
    '新しい投稿は古い投稿より高い点になる',
    () {
      final recentScore =
          calculator.calculate(
        publishedAt: now.subtract(
          const Duration(hours: 1),
        ),
        now: now,
      );

      final oldScore =
          calculator.calculate(
        publishedAt: now.subtract(
          const Duration(days: 7),
        ),
        now: now,
      );

      expect(
        recentScore,
        greaterThan(oldScore),
      );
    },
  );

  test(
    '公開日時がない場合は0点になる',
    () {
      final score = calculator.calculate(
        publishedAt: null,
        now: now,
      );

      expect(score, 0);
    },
  );
}

増加速度のテスト

collector/test/growth_score_calculator_test.dart
import 'package:sns_trend_collector/sns_trend_collector.dart';
import 'package:test/test.dart';

/// 役割:
/// GrowthScoreCalculatorの動作を検証する。
void main() {
  const calculator = GrowthScoreCalculator();

  final now = DateTime.utc(
    2026,
    7,
    24,
    12,
  );

  final item = TrendItem(
    id: 'bluesky:test',
    source: TrendSource.bluesky,
    title: 'テスト投稿',
    text: 'Flutterに関する投稿',
    authorName: 'Test User',
    publishedAt: now.subtract(
      const Duration(hours: 2),
    ),
    originalUrl:
        'https://example.com/post',
    thumbnailUrl: null,
    likeCount: 100,
    commentCount: 20,
    shareCount: 10,
    trendScore: 0,
    collectedAt: now,
  );

  test(
    '前回状態がない場合は0点になる',
    () {
      final score = calculator.calculate(
        currentItem: item,
        previousState: null,
      );

      expect(score, 0);
    },
  );

  test(
    '反応数が増えている場合は0点より大きくなる',
    () {
      final previousState =
          PreviousTrendState(
        id: item.id,
        likeCount: 20,
        commentCount: 5,
        shareCount: 2,
        collectedAt: now.subtract(
          const Duration(hours: 3),
        ),
      );

      final score = calculator.calculate(
        currentItem: item,
        previousState: previousState,
      );

      expect(
        score,
        greaterThan(0),
      );
    },
  );
}

テストを実行する

cd collector

dart format .
dart analyze
dart test

エラーがなければ、ローカル収集を実行します。

dart run bin/collect.dart

同じ処理を3時間程度空けて再実行すると、Mastodon投稿やBluesky投稿について増加速度を計算できるようになります。

初回実行では前回データが存在しないため、増加速度は0点です。


話題度の調整方法

本教材で使用した値は、MVPを動かすための初期設定です。

新しさの半減時間
24時間

反応数の基準値
1,000

1時間あたり増加量の基準値
50

キーワード比較期間
6時間

実際のデータを確認しながら調整します。

古い投稿が上位に残りすぎる場合

新しさの半減時間を短くします。

const FreshnessScoreCalculator(
  halfLife: Duration(hours: 12),
);

新しい投稿ばかりが上位になる場合

半減時間を長くします。

const FreshnessScoreCalculator(
  halfLife: Duration(hours: 48),
);

少数の巨大投稿が上位を独占する場合

反応数の最大点を下げます。

const EngagementScoreCalculator(
  maximumScore: 15,
);

急上昇投稿を重視する場合

増加速度の最大点を増やします。

ただし、合計が100点を超えないよう、ほかの配点も調整します。

RSSがほとんど上位に入らない場合

次の点を確認します。

  • RSSの公開日時を取得できているか
  • SNSと同じURLが設定されているか
  • 監視キーワードが記事に含まれているか
  • 同じテーマが複数の配信元で扱われているか

RSSには反応数がないため、新しさ、複数情報源、キーワード上昇度が主な評価要素になります。


まとめ

このページでは、収集した投稿や記事へ、アプリ独自の話題度を設定しました。

最終的な計算は次の構成です。

投稿の新しさ
最大25点
        +
現在の反応数
最大25点
        +
前回からの増加速度
最大25点
        +
複数情報源での掲載
最大15点
        +
キーワードの増加傾向
最大10点
        ↓
最終的な話題度
最大100点

情報源によって取得できる値が異なるため、すべてを同じ方法では評価していません。

Mastodon・Bluesky
├─ 新しさ
├─ 反応数
├─ 増加速度
├─ 複数情報源
└─ キーワード上昇度

RSS/Atom
├─ 新しさ
├─ 複数情報源
└─ キーワード上昇度

YouTube検索
├─ 新しさ
├─ 複数情報源
└─ キーワード上昇度

話題度は絶対的な真実ではなく、このアプリが取得した範囲内での相対的な順位です。

ランキング結果を確認しながら、半減時間、反応数の重み、比較期間などを調整します。

次のページでは、Supabaseへ保存された情報をFlutterから読み込み、トレンド一覧、情報源別フィルター、キーワード検索、元投稿へのリンクを実装します。

FAQ

よくある質問

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