CONTENT
ここから
前のページでは、Flutterアプリ、収集プログラム、Supabase、GitHub Actionsの役割を分けました。
このページでは、実際に次の情報源へ接続します。
- Mastodonのトレンドタグ
- Mastodonのトレンド投稿
- Blueskyの公開投稿
- RSS/Atomフィード
- YouTube動画
各サービスから返されるデータの構造は異なります。
そのまま保存すると、Flutter側でSNSごとに異なる表示処理が必要になります。そこで、収集プログラムの中で、すべてのデータを共通のTrendItemへ変換します。
Mastodonのタグ ─┐
Mastodonの投稿 ─┤
Blueskyの投稿 ─┤
RSSの記事 ├─→ TrendItem
YouTubeの動画 ─┘
このページでは、まだSupabaseへの保存は行いません。
まずは各情報源からデータを取得し、TrendItemへ変換できる状態を完成させます。Supabaseへの保存と重複登録の防止は、次のページで実装します。
3.1 共通データモデルTrendItemを定義する
SNSごとに異なるデータを扱うため、最初に共通データモデルを作成します。
今回使用する項目は次のとおりです。
TrendItem
├─ id
├─ source
├─ title
├─ text
├─ authorName
├─ publishedAt
├─ originalUrl
├─ thumbnailUrl
├─ likeCount
├─ commentCount
├─ shareCount
├─ trendScore
└─ collectedAt
各項目の役割
| 項目 | 役割 |
|---|---|
id | 情報源を含めた一意の識別子 |
source | Mastodon、Bluesky、RSSなどの種類 |
title | 一覧画面へ表示する短い見出し |
text | 投稿本文または記事概要 |
authorName | 投稿者、チャンネル、配信元の名前 |
publishedAt | 元情報が公開された日時 |
originalUrl | 元投稿や元記事を開くURL |
thumbnailUrl | サムネイル画像のURL |
likeCount | いいね数 |
commentCount | コメントまたは返信数 |
shareCount | 共有、リポスト、ブーストなどの数 |
trendScore | アプリ内で計算した話題度 |
collectedAt | 収集プログラムが取得した日時 |
すべての情報源から、すべての値を取得できるとは限りません。
例えば、通常のRSSには、いいね数や共有数が含まれていません。そのため、取得できない項目は推測で補わず、nullとして扱います。
RSSの記事
├─ title → 取得できる
├─ publishedAt → 取得できる場合が多い
├─ originalUrl → 取得できる
├─ likeCount → 取得できない
├─ commentCount → 取得できない
└─ shareCount → 取得できない
必要なパッケージを追加する
前のページで追加したパッケージに加えて、HTMLの除去とID生成に使用するパッケージを追加します。
cd collector
dart pub add html
dart pub add crypto
cd ..
それぞれの役割は次のとおりです。
| パッケージ | 役割 |
|---|---|
html | MastodonやRSSに含まれるHTMLをプレーンテキストへ変換する |
crypto | URLなどから安定した一意IDを生成する |
TrendSourceを定義する
次のファイルを作成します。
collector/lib/src/models/trend_source.dart
/// 役割: 収集した情報の取得元を表す。
enum TrendSource {
mastodonTag('mastodon_tag'),
mastodonStatus('mastodon_status'),
bluesky('bluesky'),
rss('rss'),
youtube('youtube');
const TrendSource(this.databaseValue);
/// Supabaseへ保存するときに使用する文字列。
final String databaseValue;
}
Mastodonについては、タグと投稿を分けています。
同じMastodonでも、タグと投稿ではデータの性質が大きく異なるためです。
mastodonTag
└─ 話題になっているハッシュタグ
mastodonStatus
└─ 話題になっている個別投稿
TrendItemを定義する
次のファイルを作成します。
collector/lib/src/models/trend_item.dart
import 'trend_source.dart';
/// 役割: SNSやRSSから取得した情報を共通形式で保持する。
final class TrendItem {
const TrendItem({
required this.id,
required this.source,
required this.title,
required this.text,
required this.authorName,
required this.publishedAt,
required this.originalUrl,
required this.thumbnailUrl,
required this.likeCount,
required this.commentCount,
required this.shareCount,
required this.trendScore,
required this.collectedAt,
});
/// 情報源を含めた一意の識別子。
final String id;
/// 情報の取得元。
final TrendSource source;
/// 一覧表示に使用する短い見出し。
final String title;
/// 投稿本文または記事の概要。
final String text;
/// 投稿者、チャンネル、配信元の名前。
final String? authorName;
/// 元情報が公開された日時。
///
/// RSSなどで公開日時を取得できない場合はnullとする。
final DateTime? publishedAt;
/// 元投稿または元記事のURL。
final String originalUrl;
/// サムネイル画像のURL。
final String? thumbnailUrl;
/// いいね数。
final int? likeCount;
/// コメント数または返信数。
final int? commentCount;
/// 共有、リポスト、ブーストなどの数。
final int? shareCount;
/// アプリ独自の話題度。
///
/// 詳細な計算は第5ページで実装する。
final double trendScore;
/// 収集プログラムが情報を取得した日時。
final DateTime collectedAt;
/// 役割: TrendItemをSupabaseへ保存可能なMapへ変換する。
/// 入力: なし。
/// 出力: カラム名と値を持つMap。
Map<String, Object?> toJson() {
return <String, Object?>{
'id': id,
'source': source.databaseValue,
'title': title,
'text': text,
'author_name': authorName,
'published_at': publishedAt?.toUtc().toIso8601String(),
'original_url': originalUrl,
'thumbnail_url': thumbnailUrl,
'like_count': likeCount,
'comment_count': commentCount,
'share_count': shareCount,
'trend_score': trendScore,
'collected_at': collectedAt.toUtc().toIso8601String(),
};
}
}
publishedAtはDateTime?として定義しています。
公開日時を取得できない情報に、現在時刻などを代入すると、実際には古い記事が新着として扱われる可能性があります。そのため、取得できない場合はnullを保持します。
タイトル生成処理を作成する
MastodonやBlueskyの投稿には、記事のようなタイトルがありません。
そこで、投稿本文の先頭部分から表示用タイトルを作成します。
次のファイルを作成します。
collector/lib/src/utils/text_utils.dart
import 'package:html/parser.dart' as html_parser;
/// 役割: HTMLを除去してプレーンテキストへ変換する。
/// 入力: HTMLを含む可能性がある文字列。
/// 出力: HTMLタグを除去した文字列。
String stripHtml(String value) {
final document = html_parser.parseFragment(value);
return normalizeWhitespace(document.text);
}
/// 役割: 改行や連続する空白を一つの空白へ統一する。
/// 入力: 整形前の文字列。
/// 出力: 空白を整理した文字列。
String normalizeWhitespace(String value) {
return value.replaceAll(RegExp(r'\s+'), ' ').trim();
}
/// 役割: 本文から一覧表示用の短いタイトルを作成する。
/// 入力: 元の本文と最大文字数。
/// 出力: 最大文字数以内のタイトル。
String createTitle(
String text, {
int maximumCharacters = 60,
}) {
final normalizedText = normalizeWhitespace(text);
if (normalizedText.isEmpty) {
return '本文のない投稿';
}
final codePoints = normalizedText.runes.toList(growable: false);
if (codePoints.length <= maximumCharacters) {
return normalizedText;
}
final shortened = String.fromCharCodes(
codePoints.take(maximumCharacters),
);
return '$shortened…';
}
String.substringでは、絵文字などの一部を途中で分割する可能性があります。
ここでは文字列をUnicodeコードポイント単位で処理し、途中で不正な文字列になりにくい方法を使用しています。
JSONを安全に読み取る処理を作成する
外部APIのレスポンスは、アプリ側が想定した型になっているとは限りません。
例えば、次のような違いが発生する可能性があります。
想定
like_count: 100
実際
like_count: "100"
または
like_count: null
JSONの値を直接キャストすると、1件の不正データによって収集処理全体が停止する可能性があります。
次のファイルを作成します。
collector/lib/src/utils/json_reader.dart
typedef JsonMap = Map<String, Object?>;
/// 役割: JSONの値をMapとして検証する。
/// 入力: JSONから取得した任意の値。
/// 出力: 文字列キーを持つMap。
JsonMap requireJsonMap(
Object? value, {
required String context,
}) {
if (value is JsonMap) {
return value;
}
throw FormatException(
'$context must be a JSON object.',
);
}
/// 役割: JSONの値をListとして検証する。
/// 入力: JSONから取得した任意の値。
/// 出力: Object?を要素に持つList。
List<Object?> requireJsonList(
Object? value, {
required String context,
}) {
if (value is List<Object?>) {
return value;
}
throw FormatException(
'$context must be a JSON array.',
);
}
/// 役割: 必須の文字列を取得する。
/// 入力: JSON Map、キー名、確認用の文脈。
/// 出力: 空ではない文字列。
String requireString(
JsonMap map,
String key, {
required String context,
}) {
final value = map[key];
if (value is String && value.trim().isNotEmpty) {
return value;
}
throw FormatException(
'$context.$key must be a non-empty string.',
);
}
/// 役割: 任意の文字列を取得する。
/// 入力: JSON Mapとキー名。
/// 出力: 有効な文字列。取得できない場合はnull。
String? optionalString(
JsonMap map,
String key,
) {
final value = map[key];
if (value is String && value.trim().isNotEmpty) {
return value;
}
return null;
}
/// 役割: 任意の整数を取得する。
/// 入力: JSON Mapとキー名。
/// 出力: 整数へ変換できる値。取得できない場合はnull。
int? optionalInt(
JsonMap map,
String key,
) {
final value = map[key];
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
if (value is String) {
return int.tryParse(value);
}
return null;
}
/// 役割: 任意のJSON Mapを取得する。
/// 入力: JSON Mapとキー名。
/// 出力: Mapとして取得できた値。取得できない場合はnull。
JsonMap? optionalJsonMap(
JsonMap map,
String key,
) {
final value = map[key];
if (value is JsonMap) {
return value;
}
return null;
}
/// 役割: 任意のJSON Listを取得する。
/// 入力: JSON Mapとキー名。
/// 出力: Listとして取得できた値。取得できない場合は空のList。
List<Object?> optionalJsonList(
JsonMap map,
String key,
) {
final value = map[key];
if (value is List<Object?>) {
return value;
}
return const <Object?>[];
}
/// 役割: ISO 8601形式の日時を読み取る。
/// 入力: 日時文字列。
/// 出力: UTCへ変換した日時。変換できない場合はnull。
DateTime? parseIsoDateTime(String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
return DateTime.tryParse(value)?.toUtc();
}
dynamicに依存せず、外部から取得した値をObject?として受け取り、型を確認してから使用します。
3.2 Mastodonのトレンドタグを取得する
Mastodonには、利用が増えているタグを取得する公開APIがあります。
使用するエンドポイントは次のとおりです。
GET /api/v1/trends/tags
取得件数は、limitで指定します。
2026年7月24日時点の公式仕様では、標準件数は10件、最大件数は20件です。返却順は単純な投稿日時順ではなく、Mastodon内部のトレンドスコアによって決まります。citeturn322570view0
レスポンスには、タグ名だけでなく、過去数日間の使用回数や利用アカウント数が含まれます。
[
{
"name": "Flutter",
"url": "https://example.social/tags/Flutter",
"history": [
{
"day": "1784822400",
"uses": "25",
"accounts": "18"
}
]
}
]
dayはUnix時刻、usesとaccountsは文字列で返されます。
Mastodonクライアントを作成する
次のファイルを作成します。
collector/lib/src/clients/mastodon/mastodon_client.dart
このファイルには、トレンドタグとトレンド投稿の両方を取得する処理を記述します。
import '../../models/trend_item.dart';
import '../../models/trend_source.dart';
import '../../network/resilient_http_client.dart';
import '../../utils/json_reader.dart';
import '../../utils/text_utils.dart';
/// 役割: Mastodonの公開トレンドAPIから情報を取得する。
final class MastodonClient {
MastodonClient({
required Uri instanceBaseUri,
required ResilientHttpClient httpClient,
}) : _instanceBaseUri = instanceBaseUri,
_httpClient = httpClient {
if (_instanceBaseUri.scheme != 'https') {
throw ArgumentError.value(
instanceBaseUri,
'instanceBaseUri',
'Mastodon instance must use HTTPS.',
);
}
}
final Uri _instanceBaseUri;
final ResilientHttpClient _httpClient;
/// 役割: Mastodonのトレンドタグを取得してTrendItemへ変換する。
/// 入力: 取得件数。1件から20件まで。
/// 出力: トレンドタグを表すTrendItemの一覧。
Future<List<TrendItem>> fetchTrendingTags({
int limit = 10,
}) async {
final normalizedLimit = _normalizeLimit(
limit: limit,
maximum: 20,
);
final endpoint = _instanceBaseUri.replace(
path: '/api/v1/trends/tags',
queryParameters: <String, String>{
'limit': normalizedLimit.toString(),
},
);
final decoded = await _httpClient.getJson(endpoint);
final responseItems = requireJsonList(
decoded,
context: 'Mastodon trending tags response',
);
final collectedAt = DateTime.now().toUtc();
final results = <TrendItem>[];
for (final responseItem in responseItems) {
try {
final map = requireJsonMap(
responseItem,
context: 'Mastodon trending tag',
);
results.add(
_convertTag(
map: map,
collectedAt: collectedAt,
),
);
} on FormatException catch (error) {
// 1件の不正データによって全件の取得を停止させない。
print('Skipped invalid Mastodon tag: $error');
}
}
return results;
}
/// 役割: Mastodonのトレンド投稿を取得してTrendItemへ変換する。
/// 入力: 取得件数。1件から40件まで。
/// 出力: トレンド投稿を表すTrendItemの一覧。
Future<List<TrendItem>> fetchTrendingStatuses({
int limit = 20,
}) async {
final normalizedLimit = _normalizeLimit(
limit: limit,
maximum: 40,
);
final endpoint = _instanceBaseUri.replace(
path: '/api/v1/trends/statuses',
queryParameters: <String, String>{
'limit': normalizedLimit.toString(),
},
);
final decoded = await _httpClient.getJson(endpoint);
final responseItems = requireJsonList(
decoded,
context: 'Mastodon trending statuses response',
);
final collectedAt = DateTime.now().toUtc();
final results = <TrendItem>[];
for (final responseItem in responseItems) {
try {
final map = requireJsonMap(
responseItem,
context: 'Mastodon trending status',
);
results.add(
_convertStatus(
map: map,
collectedAt: collectedAt,
),
);
} on FormatException catch (error) {
print('Skipped invalid Mastodon status: $error');
}
}
return results;
}
/// 役割: MastodonのタグレスポンスをTrendItemへ変換する。
/// 入力: タグのJSONと取得日時。
/// 出力: タグを表すTrendItem。
TrendItem _convertTag({
required JsonMap map,
required DateTime collectedAt,
}) {
final name = requireString(
map,
'name',
context: 'Mastodon tag',
);
final originalUrl = requireString(
map,
'url',
context: 'Mastodon tag',
);
final history = optionalJsonList(map, 'history');
final historyValues = _readTagHistory(history);
return TrendItem(
id:
'mastodon_tag:${_instanceBaseUri.host}:${name.toLowerCase()}',
source: TrendSource.mastodonTag,
title: '#$name',
text: 'Mastodonで利用が増えているハッシュタグです。',
authorName: _instanceBaseUri.host,
publishedAt: historyValues.latestDay,
originalUrl: originalUrl,
thumbnailUrl: null,
likeCount: null,
commentCount: null,
shareCount: null,
trendScore: historyValues.totalUses.toDouble(),
collectedAt: collectedAt,
);
}
/// 役割: Mastodonの投稿レスポンスをTrendItemへ変換する。
/// 入力: 投稿のJSONと取得日時。
/// 出力: 投稿を表すTrendItem。
TrendItem _convertStatus({
required JsonMap map,
required DateTime collectedAt,
}) {
final statusId = requireString(
map,
'id',
context: 'Mastodon status',
);
final originalUrl = requireString(
map,
'url',
context: 'Mastodon status',
);
final htmlContent = optionalString(map, 'content') ?? '';
final plainText = stripHtml(htmlContent);
final account = optionalJsonMap(map, 'account');
final authorName = _readMastodonAuthor(account);
final publishedAt = parseIsoDateTime(
optionalString(map, 'created_at'),
);
final likeCount = optionalInt(map, 'favourites_count');
final commentCount = optionalInt(map, 'replies_count');
final shareCount = optionalInt(map, 'reblogs_count');
return TrendItem(
id: 'mastodon_status:${_instanceBaseUri.host}:$statusId',
source: TrendSource.mastodonStatus,
title: createTitle(plainText),
text: plainText,
authorName: authorName,
publishedAt: publishedAt,
originalUrl: originalUrl,
thumbnailUrl: _readMastodonThumbnail(map),
likeCount: likeCount,
commentCount: commentCount,
shareCount: shareCount,
trendScore: 0,
collectedAt: collectedAt,
);
}
/// 役割: タグの履歴から使用回数と最新日を取得する。
/// 入力: history配列。
/// 出力: 合計使用回数と最新日。
_MastodonTagHistory _readTagHistory(
List<Object?> history,
) {
var totalUses = 0;
DateTime? latestDay;
for (final historyItem in history) {
if (historyItem is! JsonMap) {
continue;
}
totalUses += optionalInt(historyItem, 'uses') ?? 0;
final unixSeconds = optionalInt(historyItem, 'day');
if (unixSeconds == null) {
continue;
}
final day = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
);
if (latestDay == null || day.isAfter(latestDay)) {
latestDay = day;
}
}
return _MastodonTagHistory(
totalUses: totalUses,
latestDay: latestDay,
);
}
/// 役割: Mastodon投稿の表示名を取得する。
/// 入力: accountオブジェクト。
/// 出力: 表示名。取得できない場合はnull。
String? _readMastodonAuthor(JsonMap? account) {
if (account == null) {
return null;
}
final displayName = optionalString(account, 'display_name');
if (displayName != null) {
return stripHtml(displayName);
}
return optionalString(account, 'acct');
}
/// 役割: Mastodon投稿から表示可能な画像URLを取得する。
/// 入力: 投稿のJSON。
/// 出力: 画像URL。取得できない場合はnull。
String? _readMastodonThumbnail(JsonMap map) {
final attachments = optionalJsonList(
map,
'media_attachments',
);
for (final attachment in attachments) {
if (attachment is! JsonMap) {
continue;
}
final previewUrl = optionalString(
attachment,
'preview_url',
);
if (previewUrl != null) {
return previewUrl;
}
}
final card = optionalJsonMap(map, 'card');
if (card != null) {
return optionalString(card, 'image');
}
return null;
}
/// 役割: APIへ渡す件数を許容範囲内へ調整する。
/// 入力: 指定件数と最大件数。
/// 出力: 1以上、最大件数以下の値。
int _normalizeLimit({
required int limit,
required int maximum,
}) {
if (limit < 1) {
return 1;
}
if (limit > maximum) {
return maximum;
}
return limit;
}
}
/// 役割: Mastodonタグ履歴の集計結果を保持する。
final class _MastodonTagHistory {
const _MastodonTagHistory({
required this.totalUses,
required this.latestDay,
});
final int totalUses;
final DateTime? latestDay;
}
タグのtrendScoreについて
Mastodonのタグには、通常の投稿のようないいね数がありません。
そこで、この段階では過去のusesを合計し、仮のtrendScoreとして設定しています。
Mastodonタグの仮スコア
= historyに含まれるusesの合計
この値を最終的なランキングへそのまま使うわけではありません。
第5ページで、情報源ごとの数値差を補正し、共通の話題度を計算します。
3.3 Mastodonのトレンド投稿を取得する
Mastodonでは、反応が増えている投稿も取得できます。
使用するエンドポイントは次のとおりです。
GET /api/v1/trends/statuses
2026年7月24日時点の公式仕様では、標準件数は20件、最大件数は40件です。公開エンドポイントとして提供されていますが、取得結果は接続先インスタンスの利用者や管理方針によって異なります。
投稿には、次のような情報が含まれます。
Mastodonの投稿
├─ id
├─ created_at
├─ content
├─ url
├─ account
├─ favourites_count
├─ replies_count
├─ reblogs_count
├─ media_attachments
└─ card
前節で作成したMastodonClientには、すでに次のメソッドを実装しています。
Future<List<TrendItem>> fetchTrendingStatuses({
int limit = 20,
})
HTMLをそのまま保存しない
Mastodonのcontentには、HTMLが含まれます。
<p>Flutterでアプリを作っています。</p>
このHTMLをそのままFlutterへ表示すると、タグが文字列として見えたり、想定外のHTMLが残ったりする可能性があります。
そのため、収集時にプレーンテキストへ変換します。
変換前
<p>Flutterでアプリを作っています。</p>
変換後
Flutterでアプリを作っています。
今回のコードでは、stripHtmlを使って変換しています。
final htmlContent = optionalString(map, 'content') ?? '';
final plainText = stripHtml(htmlContent);
反応数を共通形式へ変換する
Mastodonの各数値は、次のようにTrendItemへ対応させます。
| Mastodon | TrendItem |
|---|---|
favourites_count | likeCount |
replies_count | commentCount |
reblogs_count | shareCount |
Mastodonでの共有操作は一般に「ブースト」と呼ばれますが、共通モデルではshareCountとして扱います。
Mastodonクライアントを試す
一時的な確認用ファイルを作成します。
collector/bin/test_mastodon.dart
import 'dart:convert';
import 'package:sns_trend_collector/sns_trend_collector.dart';
/// 役割: Mastodonのトレンド取得を単独で確認する。
/// 入力: なし。
/// 出力: 取得結果を標準出力へ表示する。
Future<void> main() async {
final httpClient = ResilientHttpClient();
try {
final mastodonClient = MastodonClient(
instanceBaseUri: Uri.parse(
'https://mastodon.social',
),
httpClient: httpClient,
);
final tags = await mastodonClient.fetchTrendingTags(
limit: 10,
);
final statuses =
await mastodonClient.fetchTrendingStatuses(
limit: 20,
);
final items = <TrendItem>[
...tags,
...statuses,
];
final output = items
.map((item) => item.toJson())
.toList(growable: false);
print(
const JsonEncoder.withIndent(' ').convert(output),
);
} finally {
httpClient.close();
}
}
実行します。
cd collector
dart run bin/test_mastodon.dart
取得に成功すると、TrendItemへ変換されたJSONが表示されます。
{
"id": "mastodon_status:mastodon.social:123456789",
"source": "mastodon_status",
"title": "Flutterで新しいアプリを作っています…",
"text": "Flutterで新しいアプリを作っています。",
"author_name": "投稿者名",
"published_at": "2026-07-24T01:00:00.000Z",
"original_url": "https://mastodon.social/@user/123456789",
"thumbnail_url": null,
"like_count": 20,
"comment_count": 4,
"share_count": 8,
"trend_score": 0.0,
"collected_at": "2026-07-24T02:00:00.000Z"
}
Mastodonのインスタンスによっては、トレンド投稿が0件になる場合があります。
これは必ずしもプログラムの不具合ではありません。
0件になる可能性
├─ トレンド対象が存在しない
├─ 管理者がトレンドを承認していない
├─ 公開プレビューが制限されている
├─ 対応するMastodonのバージョンが古い
└─ 一時的にAPIへ接続できない
複数インスタンスから収集すると情報量は増えますが、重複投稿も増えます。MVPでは、まず一つのインスタンスから取得します。
3.4 Blueskyの公開投稿を検索する
Blueskyでは、公開投稿を検索できます。
本教材では、公開閲覧用に次のホストを使用します。
public.api.bsky.app
Blueskyの公式資料では、認証を必要としない公開Web用途について、キャッシュされたpublic.api.bsky.appの使用が案内されています。
使用するエンドポイントは次のとおりです。
GET /xrpc/app.bsky.feed.searchPosts
検索条件として、主に次の値を渡します。
| パラメータ | 役割 |
|---|---|
q | 検索キーワード |
limit | 取得件数 |
sort | 並び順 |
lang | 投稿言語 |
Blueskyクライアントを作成する
次のファイルを作成します。
collector/lib/src/clients/bluesky/bluesky_client.dart
import '../../models/trend_item.dart';
import '../../models/trend_source.dart';
import '../../network/resilient_http_client.dart';
import '../../utils/json_reader.dart';
import '../../utils/text_utils.dart';
/// 役割: Blueskyの公開AppViewから投稿を取得する。
final class BlueskyClient {
BlueskyClient({
required ResilientHttpClient httpClient,
}) : _httpClient = httpClient;
final ResilientHttpClient _httpClient;
/// 役割: 指定キーワードに一致する公開投稿を検索する。
/// 入力: 検索キーワード、取得件数、言語。
/// 出力: Bluesky投稿を表すTrendItemの一覧。
Future<List<TrendItem>> searchPosts({
required String query,
int limit = 25,
String language = 'ja',
}) async {
final normalizedQuery = query.trim();
if (normalizedQuery.isEmpty) {
throw ArgumentError.value(
query,
'query',
'Search query must not be empty.',
);
}
final normalizedLimit = _normalizeLimit(limit);
final endpoint = Uri.https(
'public.api.bsky.app',
'/xrpc/app.bsky.feed.searchPosts',
<String, String>{
'q': normalizedQuery,
'limit': normalizedLimit.toString(),
'sort': 'latest',
'lang': language,
},
);
final decoded = await _httpClient.getJson(
endpoint,
headers: <String, String>{
'Accept-Language': language,
},
);
final responseMap = requireJsonMap(
decoded,
context: 'Bluesky search response',
);
final posts = requireJsonList(
responseMap['posts'],
context: 'Bluesky search response.posts',
);
final collectedAt = DateTime.now().toUtc();
final results = <TrendItem>[];
for (final post in posts) {
try {
final postMap = requireJsonMap(
post,
context: 'Bluesky post',
);
results.add(
_convertPost(
map: postMap,
collectedAt: collectedAt,
),
);
} on FormatException catch (error) {
print('Skipped invalid Bluesky post: $error');
}
}
return results;
}
/// 役割: Blueskyの投稿をTrendItemへ変換する。
/// 入力: 投稿のJSONと取得日時。
/// 出力: Bluesky投稿を表すTrendItem。
TrendItem _convertPost({
required JsonMap map,
required DateTime collectedAt,
}) {
final atUri = requireString(
map,
'uri',
context: 'Bluesky post',
);
final author = requireJsonMap(
map['author'],
context: 'Bluesky post.author',
);
final handle = requireString(
author,
'handle',
context: 'Bluesky post.author',
);
final record = requireJsonMap(
map['record'],
context: 'Bluesky post.record',
);
final text = requireString(
record,
'text',
context: 'Bluesky post.record',
);
final publishedAt = parseIsoDateTime(
optionalString(record, 'createdAt'),
);
final displayName = optionalString(
author,
'displayName',
);
final repostCount = optionalInt(map, 'repostCount') ?? 0;
final quoteCount = optionalInt(map, 'quoteCount') ?? 0;
return TrendItem(
id: 'bluesky:$atUri',
source: TrendSource.bluesky,
title: createTitle(text),
text: normalizeWhitespace(text),
authorName: displayName ?? handle,
publishedAt: publishedAt,
originalUrl: _createPostUrl(
atUri: atUri,
handle: handle,
),
thumbnailUrl: _readThumbnail(map),
likeCount: optionalInt(map, 'likeCount'),
commentCount: optionalInt(map, 'replyCount'),
shareCount: repostCount + quoteCount,
trendScore: 0,
collectedAt: collectedAt,
);
}
/// 役割: AT URIからBlueskyのWeb投稿URLを作成する。
/// 入力: AT URIと投稿者のハンドル。
/// 出力: ブラウザで開ける投稿URL。
String _createPostUrl({
required String atUri,
required String handle,
}) {
final segments = atUri
.split('/')
.where((segment) => segment.isNotEmpty)
.toList(growable: false);
if (segments.isEmpty) {
throw FormatException(
'Bluesky post URI is invalid.',
);
}
final recordKey = segments.last;
return Uri(
scheme: 'https',
host: 'bsky.app',
pathSegments: <String>[
'profile',
handle,
'post',
recordKey,
],
).toString();
}
/// 役割: Blueskyの埋め込みデータから画像URLを取得する。
/// 入力: 投稿のJSON。
/// 出力: 画像URL。取得できない場合はnull。
String? _readThumbnail(JsonMap map) {
final embed = optionalJsonMap(map, 'embed');
if (embed == null) {
return null;
}
final images = optionalJsonList(embed, 'images');
for (final image in images) {
if (image is! JsonMap) {
continue;
}
final thumbnail = optionalString(image, 'thumb');
if (thumbnail != null) {
return thumbnail;
}
}
final external = optionalJsonMap(embed, 'external');
if (external != null) {
final externalThumbnail = optionalString(
external,
'thumb',
);
if (externalThumbnail != null) {
return externalThumbnail;
}
}
return optionalString(embed, 'thumbnail');
}
/// 役割: 検索件数を1件から100件の範囲へ調整する。
/// 入力: 指定された件数。
/// 出力: 許容範囲内の件数。
int _normalizeLimit(int limit) {
if (limit < 1) {
return 1;
}
if (limit > 100) {
return 100;
}
return limit;
}
}
リポストと引用をshareCountへまとめる
Blueskyでは、投稿を広める操作として、主に次の2種類があります。
repostCount
└─ 投稿をそのままリポストした回数
quoteCount
└─ コメントを付けて引用した回数
共通モデルにはshareCountしかないため、MVPでは次のように合算します。
shareCount
= repostCount
+ quoteCount
これはアプリ内の便宜的な統一です。
リポストと引用を別々に分析したくなった場合は、将来的にモデルへrepostCountとquoteCountを追加します。
Blueskyクライアントを試す
次のファイルを作成します。
collector/bin/test_bluesky.dart
import 'dart:convert';
import 'package:sns_trend_collector/sns_trend_collector.dart';
/// 役割: Blueskyの公開投稿検索を単独で確認する。
/// 入力: なし。
/// 出力: 取得結果を標準出力へ表示する。
Future<void> main() async {
final httpClient = ResilientHttpClient();
try {
final blueskyClient = BlueskyClient(
httpClient: httpClient,
);
final items = await blueskyClient.searchPosts(
query: 'Flutter',
limit: 20,
language: 'ja',
);
final output = items
.map((item) => item.toJson())
.toList(growable: false);
print(
const JsonEncoder.withIndent(' ').convert(output),
);
} finally {
httpClient.close();
}
}
実行します。
cd collector
dart run bin/test_bluesky.dart
取得結果は必ずしも「社会全体のトレンド」ではない
searchPostsで取得するのは、指定キーワードに一致した投稿です。
取得できるもの
└─ 「Flutter」を含む公開投稿
取得できないもの
└─ 社会全体で最も話題になっている単語の完全な一覧
そのため、複数の検索キーワードをあらかじめ登録し、それぞれの投稿数や反応数を集計します。
登録キーワード
├─ Flutter
├─ AI
├─ 医療
└─ 地域名
キーワードそのものを自動発見する仕組みは、第5ページの急上昇キーワード抽出で扱います。
3.5 RSS/Atomフィードを取得する
RSSとAtomは、Webサイトの更新情報を配信するためのXML形式です。
Atomでは、フィード内に複数のentryが含まれ、それぞれにタイトル、ID、更新日時、リンクなどの情報を持たせます。
代表的な構造は次のとおりです。
RSS 2.0
<rss version="2.0">
<channel>
<title>ニュースサイト</title>
<item>
<title>記事タイトル</title>
<link>https://example.com/article</link>
<description>記事の概要</description>
<pubDate>Fri, 24 Jul 2026 08:00:00 GMT</pubDate>
</item>
</channel>
</rss>
Atom
<feed xmlns="http://www.w3.org/2005/Atom">
<title>公式ブログ</title>
<entry>
<title>記事タイトル</title>
<link
rel="alternate"
href="https://example.com/article"
/>
<id>article-123</id>
<published>2026-07-24T08:00:00Z</published>
<summary>記事の概要</summary>
</entry>
</feed>
RSSとAtomでは、項目名やURLの持ち方が異なります。
そのため、同じクライアント内で形式を判定して変換します。
RSSクライアントを作成する
次のファイルを作成します。
collector/lib/src/clients/rss/rss_client.dart
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:xml/xml.dart';
import '../../models/trend_item.dart';
import '../../models/trend_source.dart';
import '../../network/resilient_http_client.dart';
import '../../utils/text_utils.dart';
/// 役割: RSSまたはAtomフィードから記事を取得する。
final class RssClient {
RssClient({
required ResilientHttpClient httpClient,
}) : _httpClient = httpClient;
final ResilientHttpClient _httpClient;
/// 役割: 指定したRSSまたはAtomフィードを取得する。
/// 入力: フィードURLと最大取得件数。
/// 出力: 記事を表すTrendItemの一覧。
Future<List<TrendItem>> fetchFeed({
required Uri feedUri,
int limit = 30,
}) async {
if (feedUri.scheme != 'https') {
throw ArgumentError.value(
feedUri,
'feedUri',
'Feed URL must use HTTPS.',
);
}
final normalizedLimit = _normalizeLimit(limit);
final xmlText = await _httpClient.getText(feedUri);
final document = XmlDocument.parse(xmlText);
final collectedAt = DateTime.now().toUtc();
final rssItems = _findElementsByLocalName(
document,
'item',
);
if (rssItems.isNotEmpty) {
return _convertRssItems(
items: rssItems.take(normalizedLimit),
feedUri: feedUri,
collectedAt: collectedAt,
);
}
final atomEntries = _findElementsByLocalName(
document,
'entry',
);
if (atomEntries.isNotEmpty) {
return _convertAtomEntries(
entries: atomEntries.take(normalizedLimit),
feedUri: feedUri,
collectedAt: collectedAt,
);
}
throw FormatException(
'The document does not contain RSS items or Atom entries.',
);
}
/// 役割: RSS 2.0のitemをTrendItemへ変換する。
/// 入力: item一覧、フィードURL、取得日時。
/// 出力: RSS記事を表すTrendItemの一覧。
List<TrendItem> _convertRssItems({
required Iterable<XmlElement> items,
required Uri feedUri,
required DateTime collectedAt,
}) {
final results = <TrendItem>[];
for (final item in items) {
final title = _childText(item, 'title');
final link = _childText(item, 'link');
if (title == null || link == null) {
continue;
}
final description =
_childText(item, 'description') ?? '';
final authorName =
_childText(item, 'creator') ??
_childText(item, 'author') ??
feedUri.host;
final guid = _childText(item, 'guid');
final pubDate = _childText(item, 'pubDate');
results.add(
TrendItem(
id: 'rss:${_createHash(guid ?? link)}',
source: TrendSource.rss,
title: stripHtml(title),
text: stripHtml(description),
authorName: authorName,
publishedAt: _parseFeedDate(pubDate),
originalUrl: link,
thumbnailUrl: _readRssThumbnail(item),
likeCount: null,
commentCount: null,
shareCount: null,
trendScore: 0,
collectedAt: collectedAt,
),
);
}
return results;
}
/// 役割: AtomのentryをTrendItemへ変換する。
/// 入力: entry一覧、フィードURL、取得日時。
/// 出力: Atom記事を表すTrendItemの一覧。
List<TrendItem> _convertAtomEntries({
required Iterable<XmlElement> entries,
required Uri feedUri,
required DateTime collectedAt,
}) {
final results = <TrendItem>[];
for (final entry in entries) {
final title = _childText(entry, 'title');
final link = _readAtomLink(entry);
if (title == null || link == null) {
continue;
}
final summary =
_childText(entry, 'summary') ??
_childText(entry, 'content') ??
'';
final authorElement = _firstChildElement(
entry,
'author',
);
final authorName = authorElement == null
? feedUri.host
: _childText(authorElement, 'name');
final entryId = _childText(entry, 'id') ?? link;
final published =
_childText(entry, 'published') ??
_childText(entry, 'updated');
results.add(
TrendItem(
id: 'rss:${_createHash(entryId)}',
source: TrendSource.rss,
title: stripHtml(title),
text: stripHtml(summary),
authorName: authorName,
publishedAt: _parseFeedDate(published),
originalUrl: link,
thumbnailUrl: _readAtomThumbnail(entry),
likeCount: null,
commentCount: null,
shareCount: null,
trendScore: 0,
collectedAt: collectedAt,
),
);
}
return results;
}
/// 役割: XML全体から指定したローカル名の要素を探す。
/// 入力: XMLノードと要素名。
/// 出力: 名前空間を問わず一致した要素の一覧。
List<XmlElement> _findElementsByLocalName(
XmlNode node,
String localName,
) {
return node.descendants
.whereType<XmlElement>()
.where(
(element) => element.name.local == localName,
)
.toList(growable: false);
}
/// 役割: 直下の子要素から指定名の要素を取得する。
/// 入力: 親要素とローカル名。
/// 出力: 最初に一致した要素。存在しない場合はnull。
XmlElement? _firstChildElement(
XmlElement parent,
String localName,
) {
for (final child in parent.childElements) {
if (child.name.local == localName) {
return child;
}
}
return null;
}
/// 役割: 直下の子要素から文字列を取得する。
/// 入力: 親要素とローカル名。
/// 出力: 空ではない文字列。存在しない場合はnull。
String? _childText(
XmlElement parent,
String localName,
) {
final element = _firstChildElement(
parent,
localName,
);
if (element == null) {
return null;
}
final value = element.innerText.trim();
if (value.isEmpty) {
return null;
}
return value;
}
/// 役割: Atom entryから元記事URLを取得する。
/// 入力: Atomのentry要素。
/// 出力: 元記事URL。取得できない場合はnull。
String? _readAtomLink(XmlElement entry) {
for (final child in entry.childElements) {
if (child.name.local != 'link') {
continue;
}
final relationship =
child.getAttribute('rel') ?? 'alternate';
if (relationship != 'alternate') {
continue;
}
final href = child.getAttribute('href');
if (href != null && href.trim().isNotEmpty) {
return href;
}
}
return null;
}
/// 役割: RSS itemから画像URLを取得する。
/// 入力: RSSのitem要素。
/// 出力: 画像URL。取得できない場合はnull。
String? _readRssThumbnail(XmlElement item) {
for (final child in item.childElements) {
if (child.name.local == 'thumbnail') {
final url = child.getAttribute('url');
if (url != null && url.trim().isNotEmpty) {
return url;
}
}
if (child.name.local == 'enclosure') {
final type = child.getAttribute('type') ?? '';
final url = child.getAttribute('url');
if (type.startsWith('image/') &&
url != null &&
url.trim().isNotEmpty) {
return url;
}
}
}
return null;
}
/// 役割: Atom entryから画像URLを取得する。
/// 入力: Atomのentry要素。
/// 出力: 画像URL。取得できない場合はnull。
String? _readAtomThumbnail(XmlElement entry) {
for (final child in entry.childElements) {
if (child.name.local == 'thumbnail') {
final url = child.getAttribute('url');
if (url != null && url.trim().isNotEmpty) {
return url;
}
}
if (child.name.local == 'link') {
final relationship = child.getAttribute('rel');
final type = child.getAttribute('type') ?? '';
final href = child.getAttribute('href');
if (relationship == 'enclosure' &&
type.startsWith('image/') &&
href != null &&
href.trim().isNotEmpty) {
return href;
}
}
}
return null;
}
/// 役割: RSSまたはAtomの日時をDateTimeへ変換する。
/// 入力: ISO 8601またはHTTP日時形式の文字列。
/// 出力: UTC日時。変換できない場合はnull。
DateTime? _parseFeedDate(String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
final isoDate = DateTime.tryParse(value);
if (isoDate != null) {
return isoDate.toUtc();
}
try {
return HttpDate.parse(value).toUtc();
} on FormatException {
return null;
}
}
/// 役割: URLまたはフィードIDから固定長の識別子を作成する。
/// 入力: 元となる文字列。
/// 出力: SHA-256形式の識別子。
String _createHash(String value) {
return sha256.convert(utf8.encode(value)).toString();
}
/// 役割: 取得件数を1件から100件の範囲へ調整する。
/// 入力: 指定された件数。
/// 出力: 許容範囲内の件数。
int _normalizeLimit(int limit) {
if (limit < 1) {
return 1;
}
if (limit > 100) {
return 100;
}
return limit;
}
}
RSSフィードを試す
次のファイルを作成します。
collector/bin/test_rss.dart
import 'dart:convert';
import 'package:sns_trend_collector/sns_trend_collector.dart';
/// 役割: RSSまたはAtomフィードの取得を確認する。
/// 入力: なし。
/// 出力: 取得結果を標準出力へ表示する。
Future<void> main() async {
final httpClient = ResilientHttpClient();
try {
final rssClient = RssClient(
httpClient: httpClient,
);
final items = await rssClient.fetchFeed(
feedUri: Uri.parse(
'https://example.com/feed.xml',
),
limit: 20,
);
final output = items
.map((item) => item.toJson())
.toList(growable: false);
print(
const JsonEncoder.withIndent(' ').convert(output),
);
} finally {
httpClient.close();
}
}
https://example.com/feed.xmlは、実際に取得したいRSSまたはAtomフィードのURLへ変更してください。
サイトによってXML形式が異なる
RSSには、独自の名前空間や拡張項目が使われる場合があります。
例えば、サムネイル画像は次のように複数の形式で提供されます。
enclosure
media:thumbnail
media:content
独自のimage要素
今回のMVPでは、代表的なenclosureとthumbnailへ対応しています。
すべてのRSSへ完全に対応するものではありません。取得対象のフィードに独自項目がある場合は、そのフィードに合わせて処理を追加します。
記事本文を大量に保存しない
RSSに記事全文が含まれていても、MVPでは全文保存を前提にしません。
保存するのは、主に次の情報です。
- 記事タイトル
- 短い概要
- 公開日時
- 配信元
- サムネイルURL
- 元記事URL
記事の詳細は、利用者が元サイトを開いて確認します。
3.6 YouTube動画を検索する
YouTube Data APIを使用すると、指定キーワードに一致する動画を検索できます。
YouTubeは教材の任意機能です。
APIキーを発行していない場合は、この節を実装しなくても、Mastodon、Bluesky、RSSだけでアプリを完成できます。
使用するエンドポイントは次のとおりです。
GET /youtube/v3/search
search.listは、検索条件に一致する動画、チャンネル、再生リストを返します。動画だけを取得する場合は、type=videoを指定します。2026年7月24日時点では、1回の検索で検索用割り当てを1回消費し、標準で1日100回までと案内されています。citeturn322570view3
YouTubeクライアントを作成する
次のファイルを作成します。
collector/lib/src/clients/youtube/youtube_client.dart
import '../../models/trend_item.dart';
import '../../models/trend_source.dart';
import '../../network/resilient_http_client.dart';
import '../../utils/json_reader.dart';
import '../../utils/text_utils.dart';
/// 役割: YouTube Data APIから動画を検索する。
final class YouTubeClient {
YouTubeClient({
required String apiKey,
required ResilientHttpClient httpClient,
}) : _apiKey = apiKey.trim(),
_httpClient = httpClient {
if (_apiKey.isEmpty) {
throw ArgumentError(
'YouTube API key must not be empty.',
);
}
}
final String _apiKey;
final ResilientHttpClient _httpClient;
/// 役割: 指定キーワードに一致する新しい動画を検索する。
/// 入力: キーワード、取得件数、検索対象の開始日時。
/// 出力: YouTube動画を表すTrendItemの一覧。
Future<List<TrendItem>> searchVideos({
required String query,
int limit = 10,
DateTime? publishedAfter,
}) async {
final normalizedQuery = query.trim();
if (normalizedQuery.isEmpty) {
throw ArgumentError.value(
query,
'query',
'Search query must not be empty.',
);
}
final normalizedLimit = _normalizeLimit(limit);
final queryParameters = <String, String>{
'part': 'snippet',
'type': 'video',
'q': normalizedQuery,
'maxResults': normalizedLimit.toString(),
'order': 'date',
'regionCode': 'JP',
'relevanceLanguage': 'ja',
'safeSearch': 'moderate',
'key': _apiKey,
};
if (publishedAfter != null) {
queryParameters['publishedAfter'] =
publishedAfter.toUtc().toIso8601String();
}
final endpoint = Uri.https(
'www.googleapis.com',
'/youtube/v3/search',
queryParameters,
);
final decoded = await _httpClient.getJson(endpoint);
final responseMap = requireJsonMap(
decoded,
context: 'YouTube search response',
);
final items = requireJsonList(
responseMap['items'],
context: 'YouTube search response.items',
);
final collectedAt = DateTime.now().toUtc();
final results = <TrendItem>[];
for (final item in items) {
try {
final itemMap = requireJsonMap(
item,
context: 'YouTube search item',
);
final trendItem = _convertVideo(
map: itemMap,
collectedAt: collectedAt,
);
if (trendItem != null) {
results.add(trendItem);
}
} on FormatException catch (error) {
print('Skipped invalid YouTube video: $error');
}
}
return results;
}
/// 役割: YouTube検索結果をTrendItemへ変換する。
/// 入力: 検索結果のJSONと取得日時。
/// 出力: 動画を表すTrendItem。動画でない場合はnull。
TrendItem? _convertVideo({
required JsonMap map,
required DateTime collectedAt,
}) {
final idMap = requireJsonMap(
map['id'],
context: 'YouTube search item.id',
);
final videoId = optionalString(idMap, 'videoId');
if (videoId == null) {
return null;
}
final snippet = requireJsonMap(
map['snippet'],
context: 'YouTube search item.snippet',
);
final rawTitle = requireString(
snippet,
'title',
context: 'YouTube search item.snippet',
);
final rawDescription =
optionalString(snippet, 'description') ?? '';
final channelTitle = optionalString(
snippet,
'channelTitle',
);
return TrendItem(
id: 'youtube:$videoId',
source: TrendSource.youtube,
title: stripHtml(rawTitle),
text: stripHtml(rawDescription),
authorName: channelTitle,
publishedAt: parseIsoDateTime(
optionalString(snippet, 'publishedAt'),
),
originalUrl: Uri.https(
'www.youtube.com',
'/watch',
<String, String>{
'v': videoId,
},
).toString(),
thumbnailUrl: _readThumbnail(snippet),
likeCount: null,
commentCount: null,
shareCount: null,
trendScore: 0,
collectedAt: collectedAt,
);
}
/// 役割: YouTubeのsnippetからサムネイルURLを取得する。
/// 入力: snippetのJSON。
/// 出力: 画像URL。取得できない場合はnull。
String? _readThumbnail(JsonMap snippet) {
final thumbnails = optionalJsonMap(
snippet,
'thumbnails',
);
if (thumbnails == null) {
return null;
}
const preferredSizes = <String>[
'high',
'medium',
'default',
];
for (final size in preferredSizes) {
final thumbnail = optionalJsonMap(
thumbnails,
size,
);
if (thumbnail == null) {
continue;
}
final url = optionalString(thumbnail, 'url');
if (url != null) {
return url;
}
}
return null;
}
/// 役割: 取得件数を1件から50件の範囲へ調整する。
/// 入力: 指定された件数。
/// 出力: 許容範囲内の件数。
int _normalizeLimit(int limit) {
if (limit < 1) {
return 1;
}
if (limit > 50) {
return 50;
}
return limit;
}
}
検索APIだけでは反応数を取得できない
search.listで取得できるのは、主に次の情報です。
取得できる
├─ 動画ID
├─ タイトル
├─ 概要
├─ 公開日時
├─ チャンネル名
└─ サムネイル
取得できない
├─ いいね数
├─ コメント数
└─ 再生回数
そのため、この段階では次の値をnullにします。
likeCount: null,
commentCount: null,
shareCount: null,
再生回数、いいね数、コメント数が必要な場合は、取得した動画IDを使って、追加でvideos.listを呼び出します。
ただし、APIリクエスト数と実装量が増えるため、MVPでは必須にしません。
YouTubeクライアントを試す
次のファイルを作成します。
collector/bin/test_youtube.dart
import 'dart:convert';
import 'dart:io';
import 'package:dotenv/dotenv.dart';
import 'package:sns_trend_collector/sns_trend_collector.dart';
/// 役割: YouTube動画検索を単独で確認する。
/// 入力: 環境変数のYOUTUBE_API_KEY。
/// 出力: 取得結果を標準出力へ表示する。
Future<void> main() async {
final environment = DotEnv();
final envFile = File('.env');
if (envFile.existsSync()) {
environment.load();
}
final apiKey =
Platform.environment['YOUTUBE_API_KEY'] ??
environment['YOUTUBE_API_KEY'];
if (apiKey == null || apiKey.trim().isEmpty) {
throw StateError(
'YOUTUBE_API_KEY is not configured.',
);
}
final httpClient = ResilientHttpClient();
try {
final youtubeClient = YouTubeClient(
apiKey: apiKey,
httpClient: httpClient,
);
final items = await youtubeClient.searchVideos(
query: 'Flutter',
limit: 10,
publishedAfter: DateTime.now().toUtc().subtract(
const Duration(days: 2),
),
);
final output = items
.map((item) => item.toJson())
.toList(growable: false);
print(
const JsonEncoder.withIndent(' ').convert(output),
);
} finally {
httpClient.close();
}
}
collector/.envへAPIキーを設定します。
YOUTUBE_API_KEY=発行したAPIキー
実行します。
cd collector
dart run bin/test_youtube.dart
.envはGitへ追加しないでください。
3.7 エラーと取得制限を処理する
外部APIは、常に正常なレスポンスを返すとは限りません。
考えられる問題には、次のようなものがあります。
外部APIで発生する問題
├─ 通信タイムアウト
├─ DNSエラー
├─ 接続の切断
├─ HTTP 400
├─ HTTP 401
├─ HTTP 403
├─ HTTP 404
├─ HTTP 429
├─ HTTP 500
├─ HTTP 502
├─ HTTP 503
├─ 不正なJSON
└─ 必須項目の欠損
すべてのエラーに対して同じ処理を行うべきではありません。
再試行するエラー
一時的な問題である可能性が高いものは、少し時間を空けて再試行します。
再試行する
├─ 通信タイムアウト
├─ HTTP 429
├─ HTTP 500
├─ HTTP 502
├─ HTTP 503
└─ HTTP 504
再試行しないエラー
設定やリクエスト内容に問題がある場合は、同じ内容で再試行しても成功しません。
原則として再試行しない
├─ HTTP 400
├─ HTTP 401
├─ HTTP 403
├─ HTTP 404
└─ JSON形式の不正
Blueskyを含む多くのHTTP APIでは、利用制限を超えた場合にHTTP 429が返されます。Blueskyの公開AppViewには比較的余裕のある制限が設定されていますが、具体的な制限値は将来変更される可能性があります。citeturn322570view4
共通HTTPクライアントを作成する
次のディレクトリを作成します。
mkdir -p collector/lib/src/network
次のファイルを作成します。
collector/lib/src/network/resilient_http_client.dart
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
/// 役割: HTTPステータスコードを含むAPIエラーを表す。
final class HttpStatusException implements Exception {
const HttpStatusException({
required this.statusCode,
required this.uri,
required this.responsePreview,
});
final int statusCode;
final Uri uri;
final String responsePreview;
@override
String toString() {
return 'HttpStatusException('
'statusCode: $statusCode, '
'uri: $uri, '
'response: $responsePreview'
')';
}
}
/// 役割: 通信タイムアウトや接続失敗を表す。
final class NetworkRequestException implements Exception {
const NetworkRequestException({
required this.uri,
required this.message,
});
final Uri uri;
final String message;
@override
String toString() {
return 'NetworkRequestException('
'uri: $uri, '
'message: $message'
')';
}
}
/// 役割: 一時的な通信エラーへ再試行を行うHTTPクライアント。
final class ResilientHttpClient {
ResilientHttpClient({
http.Client? client,
this.maximumAttempts = 4,
this.requestTimeout = const Duration(seconds: 20),
}) : _client = client ?? http.Client() {
if (maximumAttempts < 1) {
throw ArgumentError.value(
maximumAttempts,
'maximumAttempts',
'maximumAttempts must be at least 1.',
);
}
}
final http.Client _client;
final int maximumAttempts;
final Duration requestTimeout;
/// 役割: GETリクエストを行い、JSONとしてデコードする。
/// 入力: URLと任意のHTTPヘッダー。
/// 出力: JSONから変換されたObject?。
Future<Object?> getJson(
Uri uri, {
Map<String, String>? headers,
}) async {
final response = await _getWithRetry(
uri,
headers: <String, String>{
'Accept': 'application/json',
...?headers,
},
);
try {
final Object? decoded = jsonDecode(response.body);
return decoded;
} on FormatException catch (error) {
throw FormatException(
'Response from $uri is not valid JSON: $error',
);
}
}
/// 役割: GETリクエストを行い、本文を文字列として取得する。
/// 入力: URLと任意のHTTPヘッダー。
/// 出力: HTTPレスポンス本文。
Future<String> getText(
Uri uri, {
Map<String, String>? headers,
}) async {
final response = await _getWithRetry(
uri,
headers: <String, String>{
'Accept':
'application/rss+xml, application/atom+xml, application/xml, text/xml, */*',
...?headers,
},
);
return response.body;
}
/// 役割: 一時的なエラーへ再試行しながらGETを実行する。
/// 入力: URLとHTTPヘッダー。
/// 出力: 成功したHTTPレスポンス。
Future<http.Response> _getWithRetry(
Uri uri, {
required Map<String, String> headers,
}) async {
Object? latestError;
for (
var attempt = 1;
attempt <= maximumAttempts;
attempt++
) {
try {
final response = await _client
.get(
uri,
headers: headers,
)
.timeout(requestTimeout);
if (_isSuccessful(response.statusCode)) {
return response;
}
if (!_shouldRetry(response.statusCode) ||
attempt == maximumAttempts) {
throw HttpStatusException(
statusCode: response.statusCode,
uri: uri,
responsePreview: _createResponsePreview(
response.body,
),
);
}
final delay = _calculateRetryDelay(
response: response,
attempt: attempt,
);
print(
'Request to ${uri.host} returned '
'${response.statusCode}. '
'Retrying after ${delay.inSeconds} seconds.',
);
await Future<void>.delayed(delay);
} on TimeoutException catch (error) {
latestError = error;
if (attempt == maximumAttempts) {
break;
}
await Future<void>.delayed(
_calculateExponentialDelay(attempt),
);
} on http.ClientException catch (error) {
latestError = error;
if (attempt == maximumAttempts) {
break;
}
await Future<void>.delayed(
_calculateExponentialDelay(attempt),
);
}
}
throw NetworkRequestException(
uri: uri,
message: latestError?.toString() ??
'Request failed for an unknown reason.',
);
}
/// 役割: HTTPステータスが成功を表すか確認する。
/// 入力: HTTPステータスコード。
/// 出力: 200番台の場合はtrue。
bool _isSuccessful(int statusCode) {
return statusCode >= 200 && statusCode < 300;
}
/// 役割: HTTPステータスが再試行対象か判定する。
/// 入力: HTTPステータスコード。
/// 出力: 429または500番台の場合はtrue。
bool _shouldRetry(int statusCode) {
return statusCode == 429 ||
statusCode == 500 ||
statusCode == 502 ||
statusCode == 503 ||
statusCode == 504;
}
/// 役割: Retry-Afterまたは指数バックオフから待機時間を決める。
/// 入力: HTTPレスポンスと現在の試行回数。
/// 出力: 次の試行までの待機時間。
Duration _calculateRetryDelay({
required http.Response response,
required int attempt,
}) {
final retryAfter = response.headers['retry-after'];
final retryAfterSeconds = retryAfter == null
? null
: int.tryParse(retryAfter);
if (retryAfterSeconds != null &&
retryAfterSeconds > 0) {
final limitedSeconds = retryAfterSeconds > 60
? 60
: retryAfterSeconds;
return Duration(seconds: limitedSeconds);
}
return _calculateExponentialDelay(attempt);
}
/// 役割: 試行回数に応じた指数バックオフ時間を計算する。
/// 入力: 現在の試行回数。
/// 出力: 最大60秒の待機時間。
Duration _calculateExponentialDelay(int attempt) {
final seconds = 1 << (attempt - 1);
final limitedSeconds = seconds > 60 ? 60 : seconds;
return Duration(seconds: limitedSeconds);
}
/// 役割: エラーログ用にレスポンス本文を短くする。
/// 入力: HTTPレスポンス本文。
/// 出力: 最大500文字の文字列。
String _createResponsePreview(String body) {
const maximumLength = 500;
if (body.length <= maximumLength) {
return body;
}
return '${body.substring(0, maximumLength)}…';
}
/// 役割: 内部で使用しているHTTP接続を終了する。
/// 入力: なし。
/// 出力: なし。
void close() {
_client.close();
}
}
HTTP 429の待機処理
HTTP 429を受け取った場合は、次の順番で待機時間を決めます。
HTTP 429
↓
Retry-Afterヘッダーがある?
├─ ある
│ └─ 指定秒数だけ待機する
│
└─ ない
└─ 指数バックオフで待機する
指数バックオフでは、再試行するたびに待機時間を増やします。
1回目の失敗 → 1秒
2回目の失敗 → 2秒
3回目の失敗 → 4秒
4回目の失敗 → 終了
無制限に再試行すると、GitHub Actionsの実行時間を消費し続けます。
そのため、今回は最大4回までに制限します。
YouTubeの利用上限エラー
YouTubeでは、APIの割り当てを使い切ると、HTTP 403が返される場合があります。
HTTP 403は、同じリクエストを数秒後に再送しても解決しない可能性が高いため、自動再試行の対象にしません。
HTTP 403
↓
自動再試行しない
↓
ログへ記録
↓
他の情報源の収集を継続する
YouTubeの取得に失敗しても、Mastodon、Bluesky、RSSの収集は続けます。
情報源ごとにエラーを分離する
一つのSNSで障害が起きたときに、すべての収集処理を停止させてはいけません。
望ましくない動作
Mastodon成功
↓
Bluesky失敗
↓
処理全体が停止
↓
RSSとYouTubeを取得できない
次のように、情報源ごとに処理を分離します。
望ましい動作
Mastodon → 成功
Bluesky → 失敗を記録
RSS → 成功
YouTube → 成功
次のファイルを作成します。
collector/lib/src/services/collection_service.dart
import '../models/trend_item.dart';
/// 役割: 一つの情報源からの収集結果を保持する。
final class SourceCollectionResult {
const SourceCollectionResult({
required this.sourceName,
required this.items,
required this.errorMessage,
});
final String sourceName;
final List<TrendItem> items;
final String? errorMessage;
/// 収集に成功したかを返す。
bool get isSuccess => errorMessage == null;
}
/// 役割: 情報源ごとにエラーを分離して収集処理を実行する。
final class CollectionService {
const CollectionService();
/// 役割: 一つの情報源から安全にデータを収集する。
/// 入力: 情報源名と収集関数。
/// 出力: 成功データまたはエラー内容。
Future<SourceCollectionResult> collectSafely({
required String sourceName,
required Future<List<TrendItem>> Function() collector,
}) async {
try {
final items = await collector();
return SourceCollectionResult(
sourceName: sourceName,
items: items,
errorMessage: null,
);
} on Object catch (error) {
return SourceCollectionResult(
sourceName: sourceName,
items: const <TrendItem>[],
errorMessage: error.toString(),
);
}
}
}
on Objectで捕捉しているのは、収集元ごとの失敗を分離するためです。
ただし、実際の運用ではエラーを握りつぶすのではなく、必ずログへ残します。
複数の情報源をまとめて取得する
最後に、今回作成したクライアントをまとめて実行します。
次のファイルを作成します。
collector/bin/collect.dart
import 'dart:convert';
import 'dart:io';
import 'package:dotenv/dotenv.dart';
import 'package:sns_trend_collector/sns_trend_collector.dart';
/// 役割: 環境変数または.envから設定値を取得する。
/// 入力: 環境変数名とDotEnv。
/// 出力: 設定値。存在しない場合はnull。
String? readEnvironmentValue({
required String name,
required DotEnv dotenv,
}) {
final platformValue = Platform.environment[name];
if (platformValue != null &&
platformValue.trim().isNotEmpty) {
return platformValue;
}
final dotenvValue = dotenv[name];
if (dotenvValue != null &&
dotenvValue.trim().isNotEmpty) {
return dotenvValue;
}
return null;
}
/// 役割: 複数のSNSとRSSから情報を収集する。
/// 入力: 環境変数と収集対象の設定。
/// 出力: 取得結果を標準出力へ表示する。
Future<void> main() async {
final dotenv = DotEnv();
final envFile = File('.env');
if (envFile.existsSync()) {
dotenv.load();
}
final httpClient = ResilientHttpClient();
const collectionService = CollectionService();
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 = readEnvironmentValue(
name: 'YOUTUBE_API_KEY',
dotenv: dotenv,
);
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),
),
);
},
),
);
}
} else {
print(
'YOUTUBE_API_KEY is not configured. '
'YouTube collection was skipped.',
);
}
final allItems = <TrendItem>[];
for (final result in results) {
if (result.isSuccess) {
print(
'${result.sourceName}: '
'${result.items.length} items collected.',
);
allItems.addAll(result.items);
} else {
print(
'${result.sourceName}: '
'collection failed: ${result.errorMessage}',
);
}
}
final output = allItems
.map((item) => item.toJson())
.toList(growable: false);
print(
const JsonEncoder.withIndent(' ').convert(output),
);
} finally {
httpClient.close();
}
}
feedUrlsには、実際に利用するRSSまたはAtomフィードを設定してください。
ライブラリの公開ファイルを整える
次のファイルを確認します。
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/models/trend_item.dart';
export 'src/models/trend_source.dart';
export 'src/network/resilient_http_client.dart';
export 'src/services/collection_service.dart';
パッケージ名が異なる場合は、テストファイルやcollect.dartのインポートを、実際のpubspec.yamlに合わせて変更します。
name: sns_trend_collector
収集処理を実行する
cd collector
dart format .
dart analyze
dart test
dart run bin/collect.dart
dart analyzeでエラーが表示された場合は、収集処理を実行する前に修正します。
取得データの違いを確認する
情報源ごとに、取得できる値は次のように異なります。
| 項目 | Mastodonタグ | Mastodon投稿 | Bluesky | RSS/Atom | YouTube検索 |
|---|---|---|---|---|---|
| タイトル | ○ | 本文から生成 | 本文から生成 | ○ | ○ |
| 本文・概要 | △ | ○ | ○ | ○ | ○ |
| 投稿者 | インスタンス名 | ○ | ○ | ○ | チャンネル名 |
| 公開日時 | 履歴日 | ○ | ○ | △ | ○ |
| 元URL | ○ | ○ | ○ | ○ | ○ |
| サムネイル | × | △ | △ | △ | ○ |
| いいね数 | × | ○ | ○ | × | × |
| コメント数 | × | ○ | ○ | × | × |
| 共有数 | × | ○ | ○ | × | × |
記号の意味は次のとおりです。
○ 原則として取得できる
△ 情報によって取得できない
× 通常は取得できない
取得できない値は、0として扱わず、nullとして保存します。
likeCount = 0
└─ いいねが0件である
likeCount = null
└─ いいね数を取得できていない
この違いは重要です。
nullを0へ変換すると、「反応がなかった」のか「取得できなかった」のか判断できなくなります。
まとめ
このページでは、複数の情報源から無料で情報を取得し、共通のTrendItemへ変換しました。
全体の処理は次のようになりました。
Mastodon
├─ トレンドタグ
└─ トレンド投稿
Bluesky
└─ キーワード検索による公開投稿
RSS/Atom
└─ ニュース・ブログ・公式発表
YouTube
└─ キーワードに一致する新着動画
↓
データを検証
↓
TrendItemへ変換
↓
収集結果を出力
各情報源の役割は異なります。
Mastodon
└─ インスタンス内ですでに判定されたトレンドを取得する
Bluesky
└─ 指定キーワードの公開投稿を収集する
RSS/Atom
└─ ニュースや公式発表を補完する
YouTube
└─ 話題に関連する新着動画を追加する
また、外部APIの障害によって収集全体が止まらないように、次の処理を実装しました。
- 通信タイムアウトの検出
- HTTP 429への待機と再試行
- HTTP 500番台への再試行
- 最大試行回数の制限
- 不正なJSONの検出
- 不正な1件だけを除外する処理
- 情報源ごとのエラー分離
- YouTube APIキー未設定時のスキップ
次のページでは、取得した
TrendItemをSupabaseへ保存します。
同じ投稿を何度も登録しないため、情報源と投稿IDを利用した重複防止、既存データの更新、GitHub Actionsによる定期収集まで実装します。