feed finder

This commit is contained in:
Fabian Freund
2025-02-23 17:12:46 +01:00
parent ccb186a26b
commit 4112779477
6 changed files with 132 additions and 10 deletions
+10 -2
View File
@@ -1,16 +1,24 @@
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:lensai/features/geckoview/domain/entities/browser_icon.dart';
part 'web_page_info.g.dart';
@CopyWith()
class WebPageInfo {
class WebPageInfo with FastEquatable {
final Uri url;
final String? title;
final BrowserIcon? favicon;
final Set<String>? feeds;
bool get isPageInfoComplete =>
(title?.isNotEmpty ?? false) && favicon != null;
WebPageInfo({required this.url, this.title, this.favicon});
WebPageInfo({required this.url, this.title, this.favicon, this.feeds});
@override
bool get cacheHash => true;
@override
List<Object?> get hashParameters => [url, title, favicon, feeds];
}
+11
View File
@@ -13,6 +13,8 @@ abstract class _$WebPageInfoCWProxy {
WebPageInfo favicon(BrowserIcon? favicon);
WebPageInfo feeds(Set<String>? feeds);
/// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebPageInfo(...).copyWith.fieldName(...)` to override fields one at a time with nullification support.
///
/// Usage
@@ -23,6 +25,7 @@ abstract class _$WebPageInfoCWProxy {
Uri url,
String? title,
BrowserIcon? favicon,
Set<String>? feeds,
});
}
@@ -41,6 +44,9 @@ class _$WebPageInfoCWProxyImpl implements _$WebPageInfoCWProxy {
@override
WebPageInfo favicon(BrowserIcon? favicon) => this(favicon: favicon);
@override
WebPageInfo feeds(Set<String>? feeds) => this(feeds: feeds);
@override
/// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebPageInfo(...).copyWith.fieldName(...)` to override fields one at a time with nullification support.
@@ -53,6 +59,7 @@ class _$WebPageInfoCWProxyImpl implements _$WebPageInfoCWProxy {
Object? url = const $CopyWithPlaceholder(),
Object? title = const $CopyWithPlaceholder(),
Object? favicon = const $CopyWithPlaceholder(),
Object? feeds = const $CopyWithPlaceholder(),
}) {
return WebPageInfo(
url: url == const $CopyWithPlaceholder()
@@ -67,6 +74,10 @@ class _$WebPageInfoCWProxyImpl implements _$WebPageInfoCWProxy {
? _value.favicon
// ignore: cast_nullable_to_non_nullable
: favicon as BrowserIcon?,
feeds: feeds == const $CopyWithPlaceholder()
? _value.feeds
// ignore: cast_nullable_to_non_nullable
: feeds as Set<String>?,
);
}
}
+12 -3
View File
@@ -13,6 +13,8 @@ import 'package:lensai/data/models/web_page_info.dart';
import 'package:lensai/extensions/nullable.dart';
import 'package:lensai/features/geckoview/domain/entities/browser_icon.dart';
import 'package:lensai/features/user/domain/repositories/cache.dart';
import 'package:lensai/presentation/controllers/website_title.dart';
import 'package:lensai/utils/feed_finder.dart';
import 'package:lensai/utils/lru_cache.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -198,10 +200,15 @@ class GenericWebsiteService extends _$GenericWebsiteService {
final title = document.querySelector('title')?.text;
final resources = _extractIcons(baseUri, document);
final feeds = await FeedFinder(
url: baseUri,
document: document,
).parse(verifyCandidates: false);
return {
'title': title,
'resources': resources.map(_serializeResource).toList(),
'feeds': feeds,
};
} finally {
client.close();
@@ -221,6 +228,7 @@ class GenericWebsiteService extends _$GenericWebsiteService {
url: url,
title: result['title'] as String?,
favicon: favicon,
feeds: result['feeds'] as Set<String>?,
);
}, exceptionHandler: handleHttpError);
}
@@ -278,9 +286,10 @@ class GenericWebsiteService extends _$GenericWebsiteService {
return cachedIcon;
}
return fetchPageInfo(
url,
).then((result) => result.flatMap((pageInfo) => pageInfo.favicon!).value);
final result = await ref.read(pageInfoProvider(url).future);
final favicon = result.favicon!;
return favicon;
}
// Future<Uri?> tryUpgradeToHttps(Uri httpUri) async {
@@ -7,7 +7,7 @@ part of 'generic_website.dart';
// **************************************************************************
String _$genericWebsiteServiceHash() =>
r'663759beb3ae7448f4d60339557ba11c1a3a687d';
r'12396df7370ebc31b0a53b786e5a3e27c6d05177';
/// See also [GenericWebsiteService].
@ProviderFor(GenericWebsiteService)
@@ -1,5 +1,4 @@
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:lensai/data/models/web_page_info.dart';
import 'package:lensai/domain/entities/equatable_image.dart';
@@ -13,7 +12,7 @@ import 'package:lensai/features/geckoview/domain/entities/states/security.dart';
part 'tab.g.dart';
@CopyWith()
class TabState extends WebPageInfo with FastEquatable {
class TabState extends WebPageInfo {
@CopyWithField(immutable: true)
final String id;
@@ -85,10 +84,9 @@ class TabState extends WebPageInfo with FastEquatable {
@override
List<Object?> get hashParameters => [
...super.hashParameters,
id,
contextId,
url,
title,
icon,
thumbnail,
progress,
+96
View File
@@ -0,0 +1,96 @@
import 'package:html/dom.dart';
import 'package:http/http.dart' as http;
class FeedFinder {
final Uri url;
final Document document;
late final String _base;
FeedFinder({required this.url, required this.document}) {
final uri = url.removeFragment();
_base = '${uri.scheme}://${uri.authority}';
}
Future<Set<String>> _verifyCandidates(Set<String> candidates) async {
final results = <String>{};
final client = http.Client();
try {
for (final candidate in candidates) {
try {
await client.get(Uri.parse(candidate));
} catch (e) {
continue;
}
results.add(candidate);
}
} finally {
client.close();
}
return results;
}
void _parseBody(Set<String> candidates) {
for (final a in document.querySelectorAll('a')) {
var href = a.attributes['href'];
if (href != null) {
if (href.contains('rss') ||
href.contains('xml') ||
href.contains('feed')) {
// Fix relative URLs
href = href.startsWith('/') ? _base + href : href;
href = href.endsWith('/') ? href.substring(0, href.length - 2) : href;
// Fix naked URLs
href = !href.startsWith('http') ? '$_base/$href' : href;
candidates.add(href);
}
}
}
}
void _parseHead(Set<String> candidates) {
for (final link in document.querySelectorAll("link[rel='alternate']")) {
final type = link.attributes['type'];
if (type != null) {
if (type.contains('rss') || type.contains('xml')) {
var href = link.attributes['href'];
if (href != null) {
// Fix relative URLs
href = href.startsWith('/') ? _base + href : href;
candidates.add(href);
}
}
}
}
}
Future<Set<String>> parse({
bool parseHead = true,
bool parseBody = true,
bool verifyCandidates = true,
}) async {
final candidates = <String>{};
// Look for feed candidates in head
if (parseHead) {
_parseHead(candidates);
}
// Look for feed candidates in body
if (parseBody) {
_parseBody(candidates);
}
// Verify candidates
if (verifyCandidates) {
return _verifyCandidates(candidates);
}
return candidates;
}
}