feat: add HTML providers for Realbooru and Paheal, hidden posts screen, and UI/search enhancements
This commit is contained in:
@@ -24,6 +24,14 @@ abstract class ContentProvider {
|
||||
Future<ProviderHealth> checkHealth();
|
||||
}
|
||||
|
||||
abstract class PostPageProvider {
|
||||
String? postPageUrl(Post post);
|
||||
}
|
||||
|
||||
abstract class MediaHeadersProvider {
|
||||
Map<String, String> mediaHeaders(Post post);
|
||||
}
|
||||
|
||||
abstract class CommentProvider {
|
||||
Future<List<PostComment>> getComments(String postId);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
class DanbooruProvider
|
||||
implements ContentProvider, CommentProvider, PostPageProvider {
|
||||
DanbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -27,6 +28,9 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class E621Provider
|
||||
implements ContentProvider, TagSuggestionProvider, CommentProvider {
|
||||
implements
|
||||
ContentProvider,
|
||||
TagSuggestionProvider,
|
||||
CommentProvider,
|
||||
PostPageProvider {
|
||||
E621Provider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -29,6 +33,9 @@ class E621Provider
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/posts/${post.id}';
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -15,7 +15,9 @@ class GelbooruProvider
|
||||
ContentProvider,
|
||||
CommentProvider,
|
||||
TagSuggestionProvider,
|
||||
TagMetadataProvider {
|
||||
TagMetadataProvider,
|
||||
PostPageProvider,
|
||||
MediaHeadersProvider {
|
||||
GelbooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -36,6 +38,20 @@ class GelbooruProvider
|
||||
Dio get dio => _dio;
|
||||
Map<String, String> get queryParameters => _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) =>
|
||||
'$baseUrl/index.php?page=post&s=view&id=${post.id}';
|
||||
|
||||
@override
|
||||
Map<String, String> mediaHeaders(Post post) {
|
||||
final host = Uri.tryParse(baseUrl)?.host;
|
||||
return {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
if (host != null && host.isNotEmpty) 'Referer': '$baseUrl/',
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -106,10 +106,15 @@ class KemonoProvider
|
||||
'itemsPerPage': limit.clamp(1, 100),
|
||||
},
|
||||
);
|
||||
final artists = _artistsFromResponse(response.data)
|
||||
.where((artist) =>
|
||||
service == null || service.isEmpty || artist.service == service)
|
||||
.toList(growable: false);
|
||||
final artists = _rankArtists(
|
||||
_artistsFromResponse(response.data)
|
||||
.where((artist) =>
|
||||
service == null ||
|
||||
service.isEmpty ||
|
||||
artist.service == service)
|
||||
.toList(growable: false),
|
||||
searchText,
|
||||
);
|
||||
if (artists.isNotEmpty) return artists;
|
||||
} catch (_) {
|
||||
// Fall through to the indexed creator endpoints.
|
||||
@@ -126,7 +131,7 @@ class KemonoProvider
|
||||
if (query != null && query.trim().isNotEmpty) 'keyword': query.trim(),
|
||||
},
|
||||
);
|
||||
return _artistsFromResponse(response.data);
|
||||
return _rankArtists(_artistsFromResponse(response.data), query ?? '');
|
||||
} catch (_) {
|
||||
return _listArtistsFromPublicApi(
|
||||
service: service,
|
||||
@@ -177,6 +182,7 @@ class KemonoProvider
|
||||
));
|
||||
}
|
||||
if (mapped.isNotEmpty || posts.isEmpty) {
|
||||
mapped.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return mapped.take(limit).toList(growable: false);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -290,7 +296,36 @@ class KemonoProvider
|
||||
artist.displayName.toLowerCase().contains(queryText) ||
|
||||
artist.id.toLowerCase().contains(queryText)))
|
||||
.toList(growable: false);
|
||||
return items.skip(offset).take(limit).toList(growable: false);
|
||||
final ranked = _rankArtists(items, queryText);
|
||||
return ranked.skip(offset).take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
List<ArtistProfile> _rankArtists(List<ArtistProfile> artists, String query) {
|
||||
final queryText = query.trim().toLowerCase();
|
||||
final ranked = [...artists];
|
||||
ranked.sort((a, b) {
|
||||
final scoreA = _artistSearchScore(a, queryText);
|
||||
final scoreB = _artistSearchScore(b, queryText);
|
||||
if (scoreA != scoreB) return scoreB.compareTo(scoreA);
|
||||
final updatedA = a.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final updatedB = b.updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final updated = updatedB.compareTo(updatedA);
|
||||
if (updated != 0) return updated;
|
||||
final count = (b.postCount ?? 0).compareTo(a.postCount ?? 0);
|
||||
if (count != 0) return count;
|
||||
return a.displayName.compareTo(b.displayName);
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
|
||||
int _artistSearchScore(ArtistProfile artist, String query) {
|
||||
if (query.isEmpty) return 0;
|
||||
final name = artist.displayName.toLowerCase();
|
||||
final id = artist.id.toLowerCase();
|
||||
if (name == query || id == query) return 100;
|
||||
if (name.startsWith(query) || id.startsWith(query)) return 70;
|
||||
if (name.contains(query) || id.contains(query)) return 40;
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<dynamic> _postItems(dynamic data) {
|
||||
|
||||
@@ -10,7 +10,11 @@ import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class MoebooruProvider
|
||||
implements ContentProvider, TagSuggestionProvider, CommentProvider {
|
||||
implements
|
||||
ContentProvider,
|
||||
TagSuggestionProvider,
|
||||
CommentProvider,
|
||||
PostPageProvider {
|
||||
MoebooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -29,6 +33,9 @@ class MoebooruProvider
|
||||
final Dio _dio;
|
||||
final Map<String, String> _queryParameters;
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/post/show/${post.id}';
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
@@ -7,8 +7,9 @@ import 'e621_provider.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
import 'kemono_provider.dart';
|
||||
import 'moebooru_provider.dart';
|
||||
import 'realbooru_provider.dart';
|
||||
import 'realbooru_html_provider.dart';
|
||||
import 'rule34_provider.dart';
|
||||
import 'rule34_paheal_provider.dart';
|
||||
|
||||
class ProviderFactory {
|
||||
ContentProvider create(ContentProviderConfig config) {
|
||||
@@ -41,13 +42,20 @@ class ProviderFactory {
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
case 'realbooru':
|
||||
return RealbooruProvider(
|
||||
case 'paheal':
|
||||
case 'rule34_paheal':
|
||||
return Rule34PahealProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
);
|
||||
case 'realbooru_html':
|
||||
return RealbooruHtmlProvider(
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
dioClient: client,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
case 'danbooru':
|
||||
return DanbooruProvider(
|
||||
|
||||
@@ -18,12 +18,30 @@ class ProviderManager {
|
||||
|
||||
final ProviderRepository _repository;
|
||||
final ProviderFactory _factory;
|
||||
final Map<String, _SuggestionCacheEntry> _suggestionCache = {};
|
||||
final Set<String> _softRetryKeys = {};
|
||||
final _softRetryController =
|
||||
StreamController<ProviderSoftRetryResult>.broadcast();
|
||||
_SuggestionProviderCacheEntry? _suggestionProviderCache;
|
||||
|
||||
static const _suggestionCacheTtl = Duration(seconds: 45);
|
||||
static const _suggestionProviderCacheTtl = Duration(seconds: 60);
|
||||
static const _suggestionTimeout = Duration(milliseconds: 1500);
|
||||
|
||||
Stream<ProviderSoftRetryResult> get softRetryResults =>
|
||||
_softRetryController.stream;
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> loadConfigs({
|
||||
bool enabledOnly = true,
|
||||
}) async {
|
||||
await _repository.ensureSeedProviders();
|
||||
return _repository.getProviders(enabledOnly: enabledOnly);
|
||||
final result = await _repository.getProviders(enabledOnly: enabledOnly);
|
||||
if (result is Error<List<ContentProviderConfig>>) return result;
|
||||
final configs = (result as Success<List<ContentProviderConfig>>)
|
||||
.data
|
||||
.where((config) => !_isLegacyRemovedConfig(config))
|
||||
.toList(growable: false);
|
||||
return Success(configs);
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProviderConfig>>> loadFeedConfigs({
|
||||
@@ -97,20 +115,34 @@ class ProviderManager {
|
||||
Failure(code: 'not_found', message: 'Provider not found'),
|
||||
);
|
||||
}
|
||||
return _repository.saveProvider(
|
||||
return _repository
|
||||
.saveProvider(
|
||||
config.copyWith(enabled: enabled, updatedAt: DateTime.now()),
|
||||
);
|
||||
)
|
||||
.then((result) {
|
||||
_clearSuggestionCaches();
|
||||
return result;
|
||||
});
|
||||
},
|
||||
onError: Error<void>.new,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> addCustomProvider(ContentProviderConfig config) {
|
||||
return _repository.saveProvider(config.copyWith(updatedAt: DateTime.now()));
|
||||
return _repository
|
||||
.saveProvider(config.copyWith(updatedAt: DateTime.now()))
|
||||
.then((result) {
|
||||
_clearSuggestionCaches();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> deleteProvider(String id) =>
|
||||
_repository.deleteProvider(id);
|
||||
Future<Result<void>> deleteProvider(String id) {
|
||||
return _repository.deleteProvider(id).then((result) {
|
||||
_clearSuggestionCaches();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<ProviderHealth>>> checkAll({int concurrency = 3}) async {
|
||||
final providersResult = await activeProviders();
|
||||
@@ -186,6 +218,14 @@ class ProviderManager {
|
||||
errorMessage: 'Search failed',
|
||||
),
|
||||
);
|
||||
_scheduleSoftSearchRetry(
|
||||
provider,
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
}
|
||||
}
|
||||
final posts = providerId == null
|
||||
@@ -228,6 +268,75 @@ class ProviderManager {
|
||||
return hash;
|
||||
}
|
||||
|
||||
void _scheduleSoftSearchRetry(
|
||||
ContentProvider provider, {
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
required int limit,
|
||||
required String? rating,
|
||||
required TopPeriodFilter topPeriod,
|
||||
}) {
|
||||
final key = [
|
||||
provider.id,
|
||||
page,
|
||||
limit,
|
||||
rating ?? '',
|
||||
topPeriod.name,
|
||||
...tags,
|
||||
].join('\u001f');
|
||||
if (!_softRetryKeys.add(key)) return;
|
||||
unawaited(Future<void>.delayed(const Duration(milliseconds: 900), () async {
|
||||
try {
|
||||
final posts = await provider.searchPosts(
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
await _repository.saveDiagnostics(
|
||||
ProviderDiagnostics(
|
||||
providerId: provider.id,
|
||||
lastSearchAt: DateTime.now(),
|
||||
lastResultCount: posts.length,
|
||||
),
|
||||
);
|
||||
await _repository.saveHealth(
|
||||
ProviderHealth(
|
||||
providerId: provider.id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: 0,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
if (posts.isNotEmpty) {
|
||||
_softRetryController.add(
|
||||
ProviderSoftRetryResult(
|
||||
providerId: provider.id,
|
||||
tags: tags,
|
||||
page: page,
|
||||
limit: limit,
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
posts: posts,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
await _repository.saveDiagnostics(
|
||||
ProviderDiagnostics(
|
||||
providerId: provider.id,
|
||||
lastSearchAt: DateTime.now(),
|
||||
lastResultCount: 0,
|
||||
lastErrorMessage: 'Search failed after retry',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_softRetryKeys.remove(key);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
Future<Result<List<PostComment>>> getComments(
|
||||
String providerId,
|
||||
String postId,
|
||||
@@ -282,6 +391,39 @@ class ProviderManager {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<String?>> getPostPageUrl(Post post) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
final matches =
|
||||
providers.where((provider) => provider.id == post.providerId);
|
||||
if (matches.isEmpty || matches.first is! PostPageProvider) {
|
||||
return const Success(null);
|
||||
}
|
||||
return Success((matches.first as PostPageProvider).postPageUrl(post));
|
||||
}
|
||||
|
||||
Future<Result<Map<String, String>>> getMediaHeaders(Post post) async {
|
||||
final providersResult = await activeProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
final matches =
|
||||
providers.where((provider) => provider.id == post.providerId);
|
||||
if (matches.isNotEmpty && matches.first is MediaHeadersProvider) {
|
||||
return Success(
|
||||
(matches.first as MediaHeadersProvider).mediaHeaders(post),
|
||||
);
|
||||
}
|
||||
return const Success({
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<Post>> enrichPostTags(Post post) async {
|
||||
final hasUsefulGroups = post.tagGroups.entries.any(
|
||||
(entry) => entry.key != 'general' && entry.value.isNotEmpty,
|
||||
@@ -313,29 +455,106 @@ class ProviderManager {
|
||||
String query, {
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (normalizedQuery.isEmpty) return const Success([]);
|
||||
final providersResult = await activeSuggestionProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>).data;
|
||||
if (providers.isEmpty) return const Success([]);
|
||||
|
||||
final cappedLimit = limit.clamp(1, 50);
|
||||
final providerIds = providers.map((provider) => provider.id).join(',');
|
||||
final cacheKey = '$normalizedQuery|$providerIds|$cappedLimit';
|
||||
final cached = _suggestionCache[cacheKey];
|
||||
if (cached != null &&
|
||||
DateTime.now().difference(cached.createdAt) < _suggestionCacheTtl) {
|
||||
return Success(cached.items.take(cappedLimit).toList(growable: false));
|
||||
}
|
||||
|
||||
final providerResults = await Future.wait(
|
||||
providers.map(
|
||||
(provider) => _suggestionsFromProvider(
|
||||
provider as TagSuggestionProvider,
|
||||
normalizedQuery,
|
||||
cappedLimit,
|
||||
),
|
||||
),
|
||||
);
|
||||
final suggestions = <String, TagSuggestion>{};
|
||||
for (final items in providerResults) {
|
||||
for (final item in items) {
|
||||
final nameKey = item.name.trim().toLowerCase();
|
||||
if (nameKey.isEmpty) continue;
|
||||
final current = suggestions[nameKey];
|
||||
if (current == null || item.postCount > current.postCount) {
|
||||
suggestions[nameKey] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
final values = suggestions.values.toList()
|
||||
..sort((a, b) {
|
||||
final count = b.postCount.compareTo(a.postCount);
|
||||
return count != 0 ? count : a.name.compareTo(b.name);
|
||||
});
|
||||
_suggestionCache[cacheKey] = _SuggestionCacheEntry(
|
||||
DateTime.now(),
|
||||
values,
|
||||
);
|
||||
_pruneSuggestionCache();
|
||||
return Success(values.take(cappedLimit).toList());
|
||||
}
|
||||
|
||||
Future<List<TagSuggestion>> _suggestionsFromProvider(
|
||||
TagSuggestionProvider provider,
|
||||
String query,
|
||||
int limit,
|
||||
) async {
|
||||
try {
|
||||
return await provider
|
||||
.suggestTags(query, limit: limit)
|
||||
.timeout(_suggestionTimeout);
|
||||
} catch (_) {
|
||||
// Suggestions are non-critical; a failed provider should not affect UI.
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
void _pruneSuggestionCache() {
|
||||
final now = DateTime.now();
|
||||
_suggestionCache.removeWhere(
|
||||
(_, entry) => now.difference(entry.createdAt) >= _suggestionCacheTtl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<List<ContentProvider>>> activeSuggestionProviders() async {
|
||||
final cached = _suggestionProviderCache;
|
||||
if (cached != null &&
|
||||
DateTime.now().difference(cached.createdAt) <
|
||||
_suggestionProviderCacheTtl) {
|
||||
return Success(cached.providers);
|
||||
}
|
||||
|
||||
final providersResult = await activeFeedProviders();
|
||||
if (providersResult is Error<List<ContentProvider>>) {
|
||||
return Error(providersResult.failure);
|
||||
}
|
||||
final providers = (providersResult as Success<List<ContentProvider>>)
|
||||
.data
|
||||
.whereType<TagSuggestionProvider>()
|
||||
.toList();
|
||||
final suggestions = <String, TagSuggestion>{};
|
||||
for (final provider in providers) {
|
||||
try {
|
||||
final items = await provider.suggestTags(query, limit: limit);
|
||||
for (final item in items) {
|
||||
suggestions.putIfAbsent(
|
||||
'${item.providerId}:${item.name}', () => item);
|
||||
}
|
||||
} catch (_) {
|
||||
// Suggestions are non-critical; a failed provider should not affect UI.
|
||||
}
|
||||
}
|
||||
final values = suggestions.values.toList()
|
||||
..sort((a, b) => b.postCount.compareTo(a.postCount));
|
||||
return Success(values.take(limit).toList());
|
||||
final providers = <ContentProvider>[
|
||||
for (final provider
|
||||
in (providersResult as Success<List<ContentProvider>>).data)
|
||||
if (provider is TagSuggestionProvider) provider,
|
||||
];
|
||||
_suggestionProviderCache = _SuggestionProviderCacheEntry(
|
||||
DateTime.now(),
|
||||
providers,
|
||||
);
|
||||
return Success(providers);
|
||||
}
|
||||
|
||||
void _clearSuggestionCaches() {
|
||||
_suggestionCache.clear();
|
||||
_suggestionProviderCache = null;
|
||||
}
|
||||
|
||||
Future<List<R>> _runLimited<T, R>(
|
||||
@@ -367,4 +586,44 @@ class ProviderManager {
|
||||
static bool _isFeedConfig(ContentProviderConfig config) {
|
||||
return !_isArtistConfig(config);
|
||||
}
|
||||
|
||||
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
|
||||
final id = config.id.toLowerCase();
|
||||
final type = config.apiType.toLowerCase();
|
||||
return id == 'cosbooru' || type == 'realbooru';
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestionCacheEntry {
|
||||
const _SuggestionCacheEntry(this.createdAt, this.items);
|
||||
|
||||
final DateTime createdAt;
|
||||
final List<TagSuggestion> items;
|
||||
}
|
||||
|
||||
class _SuggestionProviderCacheEntry {
|
||||
const _SuggestionProviderCacheEntry(this.createdAt, this.providers);
|
||||
|
||||
final DateTime createdAt;
|
||||
final List<ContentProvider> providers;
|
||||
}
|
||||
|
||||
class ProviderSoftRetryResult {
|
||||
const ProviderSoftRetryResult({
|
||||
required this.providerId,
|
||||
required this.tags,
|
||||
required this.page,
|
||||
required this.limit,
|
||||
required this.topPeriod,
|
||||
required this.posts,
|
||||
this.rating,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final List<String> tags;
|
||||
final int page;
|
||||
final int limit;
|
||||
final TopPeriodFilter topPeriod;
|
||||
final String? rating;
|
||||
final List<Post> posts;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/tag_suggestion.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class RealbooruHtmlProvider
|
||||
implements
|
||||
ContentProvider,
|
||||
PostPageProvider,
|
||||
MediaHeadersProvider,
|
||||
TagSuggestionProvider {
|
||||
RealbooruHtmlProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
}) : _dio = dioClient.dio;
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final queryTags = [
|
||||
if (tags.isEmpty) 'all' else ...tags,
|
||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||
].join(' ');
|
||||
final response = await _dio.get<String>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'post',
|
||||
's': 'list',
|
||||
'tags': queryTags,
|
||||
'pid': page * limit,
|
||||
},
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseList(response.data ?? '').take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await _dio.get<String>(
|
||||
'/index.php',
|
||||
queryParameters: {'page': 'post', 's': 'view', 'id': id},
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseDetails(id, response.data ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final posts = await searchPosts(tags: const ['all'], page: 0, limit: 1);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: posts.isEmpty ? ProviderStatus.offline : ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'realbooru-html',
|
||||
errorMessage: posts.isEmpty ? 'No posts found in HTML response' : null,
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) =>
|
||||
'$baseUrl/index.php?page=post&s=view&id=${post.id}';
|
||||
|
||||
@override
|
||||
Map<String, String> mediaHeaders(Post post) => {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/125 Mobile Safari/537.36',
|
||||
'Accept':
|
||||
'video/webm,video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Referer': postPageUrl(post),
|
||||
};
|
||||
|
||||
@override
|
||||
Future<List<TagSuggestion>> suggestTags(String query,
|
||||
{int limit = 20}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'autocomplete',
|
||||
'term': trimmed,
|
||||
},
|
||||
);
|
||||
final values = _autocompleteItems(response.data);
|
||||
return values
|
||||
.map((name) => TagSuggestion(
|
||||
name: name,
|
||||
category: TagCategory.unknown,
|
||||
postCount: 0,
|
||||
providerId: id,
|
||||
))
|
||||
.where((item) => item.name.toLowerCase().startsWith(
|
||||
trimmed.toLowerCase(),
|
||||
))
|
||||
.take(limit.clamp(1, 50))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<Post> _parseList(String html) {
|
||||
final posts = <Post>[];
|
||||
final pattern = RegExp(
|
||||
r'''<div[^>]*class=["'][^"']*\bthumb\b[^"']*["'][^>]*id=["']s(\d+)["'][\s\S]*?<a[^>]+href=["']([^"']*page=post[^"']*s=view[^"']*id=\d+[^"']*)["'][\s\S]*?<img[^>]+src=["']([^"']+)["'][^>]*>''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
for (final match in pattern.allMatches(html)) {
|
||||
final id = match.group(1) ?? '';
|
||||
final imgTag = match.group(0) ?? '';
|
||||
final previewUrl = _absolute(_decode(match.group(3) ?? ''));
|
||||
final tags = _tags(_attribute(imgTag, 'title'));
|
||||
final isVideo = imgTag.contains('#0000ff') ||
|
||||
tags.contains('webm') ||
|
||||
tags.contains('video');
|
||||
final isGif =
|
||||
!isVideo && (tags.contains('gif') || tags.contains('animated_gif'));
|
||||
final derivedOriginal =
|
||||
isVideo ? '' : _imageUrlFromThumbnail(previewUrl, gif: isGif);
|
||||
posts.add(
|
||||
Post(
|
||||
id: id,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal,
|
||||
fileUrl: isVideo ? '' : derivedOriginal,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: postPageUrl(Post(
|
||||
id: id,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl,
|
||||
sampleUrl: derivedOriginal.isEmpty ? previewUrl : derivedOriginal,
|
||||
fileUrl: isVideo ? '' : derivedOriginal,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: isVideo
|
||||
? 'video'
|
||||
: isGif
|
||||
? 'gif'
|
||||
: 'image',
|
||||
score: 0,
|
||||
)),
|
||||
createdAt: DateTime.now(),
|
||||
fileType: isVideo
|
||||
? 'video'
|
||||
: isGif
|
||||
? 'gif'
|
||||
: 'image',
|
||||
score: 0,
|
||||
tagGroups: _tagGroups(tags),
|
||||
),
|
||||
);
|
||||
}
|
||||
return posts;
|
||||
}
|
||||
|
||||
Post? _parseDetails(String postId, String html) {
|
||||
final imageTag = RegExp(
|
||||
r'''<(?:img|video)[^>]+(?:id=["']image["'][^>]*|[^>]*id=["']image["'])''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(0);
|
||||
final originalUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<a[^>]+href=["']([^"']+)["'][^>]*>\s*Original\s*</a>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final videoSources = RegExp(
|
||||
r'''<source[^>]+src=["']([^"']+\.(?:webm|mp4)[^"']*)["']''',
|
||||
caseSensitive: false,
|
||||
)
|
||||
.allMatches(html)
|
||||
.map((match) => _absolute(match.group(1) ?? ''))
|
||||
.where((url) => url.isNotEmpty)
|
||||
.toList();
|
||||
videoSources.sort((left, right) {
|
||||
final leftWebm = left.toLowerCase().split('?').first.endsWith('.webm');
|
||||
final rightWebm = right.toLowerCase().split('?').first.endsWith('.webm');
|
||||
if (leftWebm == rightWebm) return 0;
|
||||
return leftWebm ? -1 : 1;
|
||||
});
|
||||
final imageUrl = _absolute(_attribute(imageTag ?? '', 'src'));
|
||||
final mediaUrl = videoSources.isNotEmpty
|
||||
? videoSources.first
|
||||
: originalUrl.isNotEmpty
|
||||
? originalUrl
|
||||
: imageUrl;
|
||||
final sampleUrl = videoSources.length > 1 ? videoSources[1] : mediaUrl;
|
||||
final source = _attribute(
|
||||
RegExp(r'''<input[^>]+id=["']source["'][^>]*>''', caseSensitive: false)
|
||||
.firstMatch(html)
|
||||
?.group(0) ??
|
||||
'',
|
||||
'value',
|
||||
);
|
||||
final tagsText = _attribute(imageTag ?? '', 'alt').isNotEmpty
|
||||
? _attribute(imageTag ?? '', 'alt')
|
||||
: RegExp(
|
||||
r'''<textarea[^>]+id=["']tags["'][^>]*>([\s\S]*?)</textarea>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'';
|
||||
final tags = _tags(tagsText);
|
||||
if (mediaUrl.isEmpty) return null;
|
||||
return Post(
|
||||
id: postId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: imageUrl.isEmpty ? mediaUrl : imageUrl,
|
||||
sampleUrl: sampleUrl,
|
||||
fileUrl: mediaUrl,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: source.isEmpty ? postPageUrlById(postId) : source,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: _fileType(mediaUrl),
|
||||
score: 0,
|
||||
tagGroups: _tagGroups(tags),
|
||||
);
|
||||
}
|
||||
|
||||
String postPageUrlById(String postId) =>
|
||||
'$baseUrl/index.php?page=post&s=view&id=$postId';
|
||||
|
||||
String _attribute(String source, String name) {
|
||||
final match = RegExp(
|
||||
'''$name\\s*=\\s*["']([^"']*)["']''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(source);
|
||||
return _decode(match?.group(1) ?? '');
|
||||
}
|
||||
|
||||
List<String> _tags(String value) {
|
||||
return _decode(value)
|
||||
.replaceAll(',', ' ')
|
||||
.split(RegExp(r'\s+'))
|
||||
.map((tag) => tag.trim().replaceAll(' ', '_'))
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Map<String, List<String>> _tagGroups(List<String> tags) =>
|
||||
tags.isEmpty ? const {} : {'general': tags};
|
||||
|
||||
String _fileType(String url) {
|
||||
final lower = url.toLowerCase().split('?').first;
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
String _absolute(String url) {
|
||||
final value = _decode(url.trim());
|
||||
if (value.startsWith('//')) return _normalizeUrl('https:$value');
|
||||
if (value.startsWith('/')) return _normalizeUrl('$baseUrl$value');
|
||||
return _normalizeUrl(value);
|
||||
}
|
||||
|
||||
String _normalizeUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !uri.hasScheme || uri.host.isEmpty) return url;
|
||||
final normalizedPath = uri.path.replaceFirst(RegExp(r'^/+'), '/');
|
||||
return uri.replace(path: normalizedPath).toString();
|
||||
}
|
||||
|
||||
String _imageUrlFromThumbnail(String thumbnailUrl, {bool gif = false}) {
|
||||
final uri = Uri.tryParse(thumbnailUrl);
|
||||
if (uri == null) return '';
|
||||
final match = RegExp(r'/thumbnails/([^/]+)/([^/]+)/thumbnail_([^/.]+)\.')
|
||||
.firstMatch(uri.path);
|
||||
if (match == null) return '';
|
||||
final extension = gif ? 'gif' : 'jpeg';
|
||||
final path =
|
||||
'/images/${match.group(1)}/${match.group(2)}/${match.group(3)}.$extension';
|
||||
return uri.replace(path: path, query: '').toString();
|
||||
}
|
||||
|
||||
String _decode(String value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
|
||||
List<String> _autocompleteItems(dynamic data) {
|
||||
final source = data is String ? jsonDecode(data) : data;
|
||||
if (source is List) {
|
||||
return source
|
||||
.map((item) => item.toString().trim().replaceAll(' ', '_'))
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import '../../core/errors/app_exception.dart';
|
||||
import '../mappers/gelbooru_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'gelbooru_provider.dart';
|
||||
|
||||
class RealbooruProvider extends GelbooruProvider {
|
||||
RealbooruProvider({
|
||||
required super.id,
|
||||
required super.name,
|
||||
required super.baseUrl,
|
||||
required super.dioClient,
|
||||
super.queryParameters,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
final queryTags = [
|
||||
...tags,
|
||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||
if (topPeriod == TopPeriodFilter.allTime) 'sort:score:desc',
|
||||
];
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'pid': page,
|
||||
'limit': limit.clamp(1, 100),
|
||||
'tags': queryTags.join(' '),
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
_throwIfSearchFailed(response.data);
|
||||
return GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'id': id,
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
_throwIfSearchFailed(response.data);
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
response.data,
|
||||
providerId: this.id,
|
||||
providerName: name,
|
||||
);
|
||||
return posts.isEmpty ? null : posts.first;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
queryParameters: {
|
||||
'page': 'dapi',
|
||||
's': 'post',
|
||||
'q': 'index',
|
||||
'limit': 1,
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
_throwIfSearchFailed(response.data);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'realbooru-dapi',
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: 'realbooru-dapi',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _throwIfSearchFailed(dynamic data) {
|
||||
if (data is Map) {
|
||||
final success = data['success']?.toString().toLowerCase();
|
||||
if (success == 'false') {
|
||||
throw ProviderUnavailableException(
|
||||
(data['message'] ??
|
||||
data['reason'] ??
|
||||
'Realbooru search is unavailable')
|
||||
.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
final text = data?.toString().toLowerCase() ?? '';
|
||||
if (text.contains('success="false"') || text.contains("success='false'")) {
|
||||
final message = RegExp(r'''(?:message|reason)\s*=\s*["']([^"']+)["']''')
|
||||
.firstMatch(data?.toString() ?? '');
|
||||
throw ProviderUnavailableException(
|
||||
message?.group(1) ?? 'Realbooru search is unavailable',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class Rule34PahealProvider implements ContentProvider, PostPageProvider {
|
||||
Rule34PahealProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.baseUrl,
|
||||
required DioClient dioClient,
|
||||
}) : _dio = dioClient.dio;
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String baseUrl;
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
required int page,
|
||||
int limit = 50,
|
||||
String? rating,
|
||||
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
||||
}) async {
|
||||
// Paheal's public HTML list ignores common sort/top query parameters and
|
||||
// exposes no stable top-period endpoint, so non-latest filters fall back
|
||||
// to the regular list instead of pretending to support ranking.
|
||||
final query = tags
|
||||
.where((tag) => tag.trim().isNotEmpty && tag.trim() != 'all')
|
||||
.map((tag) => tag.trim().replaceAll(' ', '_'))
|
||||
.join('_');
|
||||
final path = query.isEmpty
|
||||
? '/post/list/${page + 1}'
|
||||
: '/post/list/$query/${page + 1}';
|
||||
final response = await _dio.get<String>(
|
||||
path,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseList(response.data ?? '').take(limit).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Post?> getPost(String id) async {
|
||||
final response = await _dio.get<String>(
|
||||
'/post/view/$id',
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _parseSingle(id, response.data ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProviderHealth> checkHealth() async {
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final posts = await searchPosts(tags: const [], page: 0, limit: 1);
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: posts.isEmpty ? ProviderStatus.offline : ProviderStatus.online,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
apiVersion: 'paheal-html',
|
||||
errorMessage: posts.isEmpty ? 'No posts found in HTML response' : null,
|
||||
);
|
||||
} catch (error) {
|
||||
return ProviderHealth(
|
||||
providerId: id,
|
||||
status: ProviderStatus.offline,
|
||||
pingMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||||
lastCheckedAt: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
apiVersion: 'paheal-html',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) => '$baseUrl/post/view/${post.id}';
|
||||
|
||||
List<Post> _parseList(String html) {
|
||||
final posts = <Post>[];
|
||||
final pattern = RegExp(
|
||||
r'''<div[^>]+class=['"][^'"]*\bshm-thumb\b[^'"]*['"][\s\S]*?</div>''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
for (final match in pattern.allMatches(html)) {
|
||||
final block = match.group(0) ?? '';
|
||||
final postId = _attribute(block, 'data-post-id');
|
||||
final fileUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*File Only\s*</a>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(block)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final previewUrl = _absolute(
|
||||
RegExp(r'''<img[^>]+src=['"]([^'"]+)['"]''', caseSensitive: false)
|
||||
.firstMatch(block)
|
||||
?.group(1) ??
|
||||
'',
|
||||
);
|
||||
if (postId.isEmpty || (fileUrl.isEmpty && previewUrl.isEmpty)) continue;
|
||||
final tags = _tags(_attribute(block, 'data-tags'));
|
||||
final ext = _attribute(block, 'data-ext');
|
||||
final dimensions = _dimensions(_attribute(block, 'title'));
|
||||
final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl;
|
||||
posts.add(
|
||||
Post(
|
||||
id: postId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl,
|
||||
sampleUrl: mediaUrl,
|
||||
fileUrl: mediaUrl,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: dimensions.$1,
|
||||
height: dimensions.$2,
|
||||
source: '$baseUrl/post/view/$postId',
|
||||
createdAt: DateTime.now(),
|
||||
fileType: _fileType('$ext $mediaUrl'),
|
||||
score: 0,
|
||||
tagGroups: tags.isEmpty ? const {} : {'general': tags},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (posts.isNotEmpty) return posts;
|
||||
final singleId = RegExp(r'''/post/view/(\d+)''').firstMatch(html)?.group(1);
|
||||
final single = singleId == null ? null : _parseSingle(singleId, html);
|
||||
return single == null ? const [] : [single];
|
||||
}
|
||||
|
||||
Post? _parseSingle(String postId, String html) {
|
||||
final fileUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<a[^>]+href=['"]([^'"]+)['"][^>]*>\s*(?:File|Image) Only\s*</a>''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
RegExp(
|
||||
r'''<(?:img|video|source)[^>]+src=['"]([^'"]+(?:jpg|jpeg|png|gif|webm|mp4)[^'"]*)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final previewUrl = _absolute(
|
||||
RegExp(
|
||||
r'''<meta[^>]+property=['"]og:image['"][^>]+content=['"]([^'"]+)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
final tagsText = RegExp(
|
||||
r'''<meta[^>]+name=['"]keywords['"][^>]+content=['"]([^'"]+)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html)?.group(1) ??
|
||||
'';
|
||||
final mediaUrl = fileUrl.isEmpty ? previewUrl : fileUrl;
|
||||
if (mediaUrl.isEmpty) return null;
|
||||
final tags = _tags(tagsText);
|
||||
return Post(
|
||||
id: postId,
|
||||
providerId: id,
|
||||
providerName: name,
|
||||
previewUrl: previewUrl.isEmpty ? mediaUrl : previewUrl,
|
||||
sampleUrl: mediaUrl,
|
||||
fileUrl: mediaUrl,
|
||||
tags: tags,
|
||||
rating: 'explicit',
|
||||
width: 0,
|
||||
height: 0,
|
||||
source: '$baseUrl/post/view/$postId',
|
||||
createdAt: DateTime.now(),
|
||||
fileType: _fileType(mediaUrl),
|
||||
score: 0,
|
||||
tagGroups: tags.isEmpty ? const {} : {'general': tags},
|
||||
);
|
||||
}
|
||||
|
||||
String _attribute(String source, String name) {
|
||||
return _decode(
|
||||
RegExp(
|
||||
'''$name\\s*=\\s*['"]([^'"]*)['"]''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(source)?.group(1) ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
(int, int) _dimensions(String value) {
|
||||
final match = RegExp(r'(\d+)x(\d+)').firstMatch(value);
|
||||
return (
|
||||
int.tryParse(match?.group(1) ?? '') ?? 0,
|
||||
int.tryParse(match?.group(2) ?? '') ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _tags(String value) {
|
||||
return _decode(value)
|
||||
.replaceAll(',', ' ')
|
||||
.split(RegExp(r'\s+'))
|
||||
.map((tag) => tag.trim().replaceAll(' ', '_').toLowerCase())
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _fileType(String value) {
|
||||
final lower = value.toLowerCase().split('?').first;
|
||||
if (lower.contains('webm') || lower.contains('mp4')) return 'video';
|
||||
if (lower.contains('gif')) return 'gif';
|
||||
if (lower.contains('png')) return 'png';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
String _absolute(String url) {
|
||||
final value = _decode(url.trim());
|
||||
if (value.isEmpty) return '';
|
||||
if (value.startsWith('//')) return 'https:$value';
|
||||
if (value.startsWith('/')) return '$baseUrl$value';
|
||||
return value;
|
||||
}
|
||||
|
||||
String _decode(String value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,17 @@ class Rule34Provider extends GelbooruProvider {
|
||||
super.queryParameters,
|
||||
});
|
||||
|
||||
@override
|
||||
String postPageUrl(Post post) =>
|
||||
'https://rule34.xxx/index.php?page=post&s=view&id=${post.id}';
|
||||
|
||||
@override
|
||||
Map<String, String> mediaHeaders(Post post) => const {
|
||||
'User-Agent': 'Lunaris/2.0.1 Flutter local booru browser',
|
||||
'Accept': '*/*',
|
||||
'Referer': 'https://rule34.xxx/',
|
||||
};
|
||||
|
||||
@override
|
||||
Future<List<Post>> searchPosts({
|
||||
required List<String> tags,
|
||||
|
||||
Reference in New Issue
Block a user