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
これにより、どの要素によって上位になったかを確認できます。
保存件数を修正する
CollectionRunSummaryのsavedCountには、点数計算後の件数を設定します。
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から読み込み、トレンド一覧、情報源別フィルター、キーワード検索、元投稿へのリンクを実装します。