-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfeed_service.dart
More file actions
378 lines (331 loc) · 13.9 KB
/
Copy pathfeed_service.dart
File metadata and controls
378 lines (331 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
import 'package:http/http.dart' as http;
import 'package:dart_rss/dart_rss.dart';
import 'package:html/parser.dart' show parse;
import 'package:intl/intl.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show compute;
import 'package:flutter/material.dart';
import '../models/feed_item.dart';
/// Fetches and parses RSS and Atom feeds over HTTP.
///
/// Uses browser-like User-Agent headers to avoid Cloudflare 403 challenges.
/// Attempts RSS parsing first; falls back to Atom if RSS fails.
class FeedService {
// ---------------------------------------------------------------------------
// Shared HTTP client — reuses connections (TCP keep-alive) across all feeds.
// One instance per FeedService lifetime; closed in FeedProvider.dispose().
// ---------------------------------------------------------------------------
final http.Client _client;
FeedService() : _client = http.Client();
void dispose() => _client.close();
/// Max items kept per feed. Some feeds expose large archives (hundreds of
/// entries); keeping only the most recent slice bounds memory, the unread
/// counts, and the size of the merged in-memory list.
static const int maxItemsPerFeed = 50;
/// Returns at most [maxItemsPerFeed] most-recent items. Sorts by descending
/// publication date only when the cap is exceeded — feeds are conventionally
/// reverse-chronological, but that is not guaranteed.
static List<FeedItem> capItems(List<FeedItem> items) {
if (items.length <= maxItemsPerFeed) return items;
final sorted = items.toList()
..sort((a, b) {
if (a.pubDate == null && b.pubDate == null) return 0;
if (a.pubDate == null) return 1;
if (b.pubDate == null) return -1;
return b.pubDate!.compareTo(a.pubDate!);
});
return sorted.take(maxItemsPerFeed).toList();
}
/// Stable synthetic ID for feed items exposing neither a guid nor a link.
/// Derived from feed URL + title + raw date so the same article keeps the same
/// ID across fetches. Using `DateTime.now()` here would mint a fresh ID every
/// refresh, breaking read-state tracking and re-triggering notifications.
static String fallbackId(String feedUrl, String? title, String? rawDate) =>
'gen:$feedUrl#${title ?? ''}#${rawDate ?? ''}';
// ---------------------------------------------------------------------------
// HTTP header constants
// ---------------------------------------------------------------------------
static const _userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/122.0.0.0 Safari/537.36';
static const _acceptHeader =
'application/rss+xml, application/rdf+xml, '
'application/atom+xml, application/xml, '
'text/xml, text/html;q=0.9';
// ---------------------------------------------------------------------------
// Pre-compiled RFC 822 date format patterns (avoids re-creating per call)
// ---------------------------------------------------------------------------
// Patterns WITHOUT timezone — tz offset is stripped and applied manually
// because intl's DateFormat parses but does NOT apply +HHMM offsets.
static final List<DateFormat> _rfc822Patterns = [
DateFormat('EEE, dd MMM yyyy HH:mm:ss', 'en_US'),
DateFormat('dd MMM yyyy HH:mm:ss', 'en_US'),
DateFormat('EEE, dd MMM yyyy', 'en_US'),
DateFormat('dd MMM yyyy', 'en_US'),
];
static final _tzOffsetRegex = RegExp(r'\s*([+-])(\d{2})(\d{2})\s*$');
// ---------------------------------------------------------------------------
// Timezone abbreviation → offset mapping for RFC 822 normalization
// ---------------------------------------------------------------------------
static final _timezoneReplacements = <RegExp, String>{
RegExp(r'\s+GMT$', caseSensitive: false): ' +0000',
RegExp(r'\s+UTC$', caseSensitive: false): ' +0000',
RegExp(r'\s+EST$', caseSensitive: false): ' -0500',
RegExp(r'\s+EDT$', caseSensitive: false): ' -0400',
RegExp(r'\s+CST$', caseSensitive: false): ' -0600',
RegExp(r'\s+CDT$', caseSensitive: false): ' -0500',
RegExp(r'\s+MST$', caseSensitive: false): ' -0700',
RegExp(r'\s+MDT$', caseSensitive: false): ' -0600',
RegExp(r'\s+PST$', caseSensitive: false): ' -0800',
RegExp(r'\s+PDT$', caseSensitive: false): ' -0700',
};
/// Fetches and parses the feed at [url], tagging each item with [category].
///
/// When [etag] or [lastModified] from a previous fetch are supplied, the
/// request is made conditional (`If-None-Match` / `If-Modified-Since`). A
/// `304 Not Modified` returns a [FeedFetchResult.notModified] with no body to
/// parse, so unchanged feeds cost almost nothing on every refresh cycle.
///
/// Throws if the HTTP request fails or the response cannot be parsed as either
/// RSS or Atom.
Future<FeedFetchResult> fetchFeed(
String url,
String category, {
String? etag,
String? lastModified,
}) async {
try {
final headers = <String, String>{
'User-Agent': _userAgent,
'Accept': _acceptHeader,
'Accept-Language': 'en-US,en;q=0.9',
};
if (etag != null) headers['If-None-Match'] = etag;
if (lastModified != null) headers['If-Modified-Since'] = lastModified;
final response = await _client
.get(Uri.parse(url), headers: headers)
.timeout(const Duration(seconds: 10));
// Content unchanged since the validators we sent — no body returned.
if (response.statusCode == 304) {
return FeedFetchResult.notModified(
etag: etag,
lastModified: lastModified,
);
}
if (response.statusCode != 200) {
throw Exception(
'Failed to load RSS feed (Status: ${response.statusCode})',
);
}
final newEtag = response.headers['etag'];
final newLastModified = response.headers['last-modified'];
// Decode + XML parse + per-item HTML parse in a background isolate —
// with many feeds this work caused jank on the main thread during sync.
final items = await compute(parseFeedBody, (
bodyBytes: response.bodyBytes,
category: category,
url: url,
));
return FeedFetchResult(
items: items,
etag: newEtag,
lastModified: newLastModified,
);
} catch (e) {
debugPrint('Error fetching feed $url: $e');
throw Exception('Could not fetch or parse feed.');
}
}
/// Decodes and parses a raw feed body into capped [FeedItem]s.
///
/// Static and side-effect free so it can run in a background isolate via
/// [compute]. Attempts RSS first, then falls back to Atom.
static List<FeedItem> parseFeedBody(
({Uint8List bodyBytes, String category, String url}) request,
) {
final bodyString = utf8.decode(request.bodyBytes, allowMalformed: true);
try {
final rssFeed = RssFeed.parse(bodyString);
return capItems(_mapRssItems(rssFeed, request.category, request.url));
} catch (e) {
// Try parsing as Atom if RSS parsing fails
try {
final atomFeed = AtomFeed.parse(bodyString);
return capItems(_mapAtomItems(atomFeed, request.category, request.url));
} catch (e2) {
throw Exception('Failed to parse RSS/Atom feed: $e2');
}
}
}
/// Maps RSS feed items to the universal [FeedItem] model.
static List<FeedItem> _mapRssItems(
RssFeed feed, String category, String sourceUrl) {
final siteName = _decodeHtmlEntities(feed.title ?? 'Unknown Site');
return feed.items.map((item) {
final content = item.content?.value ?? item.description ?? '';
// Parse HTML once; reuse the document for both description and images.
final parsed = _parseContent(content);
// Try to get image from enclosure if not found in content
String? topImage = parsed.images.isNotEmpty ? parsed.images.first : null;
if (topImage == null &&
item.enclosure != null &&
item.enclosure!.url != null) {
if (item.enclosure!.type?.startsWith('image') ?? false) {
topImage = item.enclosure!.url;
}
}
return FeedItem(
id: item.guid ?? item.link ?? fallbackId(sourceUrl, item.title, item.pubDate),
siteName: siteName,
title: _decodeHtmlEntities(item.title ?? 'No Title'),
description: parsed.text,
timeAgo: '',
siteIcon: Icons.rss_feed,
iconColor: const Color(0xFF00A3FF),
iconBackgroundColor: const Color(0x3300A3FF),
link: item.link ?? '',
imageUrl: topImage,
content: content,
pubDate: _parseRssDate(item.pubDate),
category: category,
feedUrl: sourceUrl,
);
}).toList();
}
/// Maps Atom feed entries to the universal [FeedItem] model.
static List<FeedItem> _mapAtomItems(
AtomFeed feed,
String category,
String sourceUrl,
) {
final siteName = _decodeHtmlEntities(feed.title ?? 'Unknown Site');
return feed.items.map((item) {
final content = item.content ?? item.summary ?? '';
// Parse HTML once; reuse the document for both description and images.
final parsed = _parseContent(content);
// YouTube uses <media:group><media:thumbnail url="...">
List<String> images = parsed.images;
if (images.isEmpty &&
item.media != null &&
item.media!.thumbnails.isNotEmpty) {
final url = item.media!.thumbnails.first.url;
if (url != null && url.isNotEmpty) {
images = [url];
}
}
String? topImage = images.isNotEmpty ? images.first : null;
String link = '';
if (item.links.isNotEmpty) {
link = item.links.first.href ?? '';
}
return FeedItem(
id:
item.id ??
(link.isNotEmpty
? link
: fallbackId(sourceUrl, item.title, item.updated ?? item.published)),
siteName: siteName,
title: _decodeHtmlEntities(item.title ?? 'No Title'),
description: parsed.text,
timeAgo: '',
siteIcon: Icons.rss_feed,
iconColor: const Color(0xFF00A3FF),
iconBackgroundColor: const Color(0x3300A3FF),
link: link,
imageUrl: topImage,
content: content.isEmpty ? (item.title ?? '') : content,
pubDate: _parseRssDate(item.updated ?? item.published),
category: category,
feedUrl: sourceUrl,
);
}).toList();
}
/// Parses [htmlString] once and extracts both plain text and image URLs.
///
/// Avoids re-parsing the same HTML twice (old code called parse() 3-4× per item).
static _ParsedContent _parseContent(String htmlString) {
if (htmlString.isEmpty) return const _ParsedContent('', []);
final document = parse(htmlString);
final text = (document.body?.text ?? '').replaceAll(RegExp(r'\s+'), ' ').trim();
final images = document
.getElementsByTagName('img')
.map((img) => img.attributes['src'])
.whereType<String>()
.toList();
return _ParsedContent(text, images);
}
/// Decodes HTML entities (e.g. `‘`) in feed titles and site names.
static String _decodeHtmlEntities(String text) {
// Fast path: no '&' means no entities — skip the full HTML parse.
if (text.isEmpty || !text.contains('&')) return text;
final document = parse(text);
return document.documentElement?.text ?? text;
}
/// Parses dates from RSS/Atom feeds.
///
/// Supports:
/// - ISO 8601 (e.g. `2026-02-27T12:00:00Z`)
/// - RFC 822 / RFC 2822 (e.g. `Thu, 27 Feb 2026 12:00:00 GMT`)
static DateTime? _parseRssDate(String? dateStr) {
if (dateStr == null || dateStr.trim().isEmpty) return null;
final trimmed = dateStr.trim();
// 1. ISO 8601 — DateTime.parse handles timezone correctly
final iso = DateTime.tryParse(trimmed);
if (iso != null) return iso;
// 2. Replace timezone abbreviations (GMT, EST…) with numeric offsets
String normalized = trimmed;
for (final entry in _timezoneReplacements.entries) {
normalized = normalized.replaceAll(entry.key, entry.value);
}
// 3. Extract numeric tz offset and strip it before parsing.
// intl's DateFormat reads but does NOT apply +HHMM offsets.
int offsetMinutes = 0;
final tzMatch = _tzOffsetRegex.firstMatch(normalized);
if (tzMatch != null) {
final sign = tzMatch.group(1) == '+' ? 1 : -1;
final h = int.parse(tzMatch.group(2)!);
final m = int.parse(tzMatch.group(3)!);
offsetMinutes = sign * (h * 60 + m);
normalized = normalized.substring(0, tzMatch.start).trim();
}
// 4. Parse datetime as UTC, then subtract the offset to get true UTC
for (final format in _rfc822Patterns) {
try {
final dt = format.parse(normalized, true);
return dt.subtract(Duration(minutes: offsetMinutes));
} catch (_) {}
}
return null;
}
}
/// Outcome of a single [FeedService.fetchFeed] call.
///
/// Carries HTTP cache validators (ETag / Last-Modified) so the next request can
/// be made conditional and an unchanged feed can short-circuit with [notModified].
class FeedFetchResult {
/// Parsed items. Empty when [notModified] (the caller reuses its own copy).
final List<FeedItem> items;
/// Validators echoed back for persistence. For a 304 these are the same ones
/// that were sent; for a 200 they come from the fresh response (may be null).
final String? etag;
final String? lastModified;
/// True when the server returned `304 Not Modified`.
final bool notModified;
const FeedFetchResult({
this.items = const [],
this.etag,
this.lastModified,
this.notModified = false,
});
const FeedFetchResult.notModified({this.etag, this.lastModified})
: items = const [],
notModified = true;
}
/// Plain text + image list extracted from a single HTML parse.
class _ParsedContent {
final String text;
final List<String> images;
const _ParsedContent(this.text, this.images);
}