Merge ticket/3-fun-section into feature/source-kinds (#3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-16 15:58:43 +02:00
commit 1a3442ea02
11 files changed
+418 -20

No files matched your search

+31
View File
@@ -361,6 +361,37 @@ const defaultSources = <NewsSource>[
kind: SourceKind.learning,
description: 'Software architecture and engineering talks, high volume',
),
// --- Fun ----------------------------------------------------------------
NewsSource(
id: 'xkcd',
name: 'xkcd',
feedUrl: 'https://xkcd.com/atom.xml',
homepage: 'https://xkcd.com',
language: SourceLanguage.en,
kind: SourceKind.fun,
description: 'Webcomic of romance, sarcasm, math and language',
enabledByDefault: true,
),
NewsSource(
id: 'thedailywtf',
name: 'The Daily WTF',
feedUrl: 'https://thedailywtf.com/rss',
homepage: 'https://thedailywtf.com',
language: SourceLanguage.en,
kind: SourceKind.fun,
description: 'IT horror stories, bad code and error messages',
enabledByDefault: true,
),
NewsSource(
id: 'programmerhumor',
name: 'r/ProgrammerHumor',
feedUrl: 'https://www.reddit.com/r/ProgrammerHumor/top/.rss?t=day',
homepage: 'https://www.reddit.com/r/ProgrammerHumor/',
language: SourceLanguage.en,
kind: SourceKind.fun,
description: "The day's top programming memes on Reddit",
),
];
/// The curated sources before the app remembered which ones it had seen.
+29 -13
View File
@@ -49,9 +49,10 @@ class ArticleExtractor {
/// Hard cap on what we send to the model per article (~10k tokens).
static const maxChars = 40000;
/// Loads the article page and returns whichever is longer: the extracted
/// page or the full content some feeds ship. Never throws; on failure
/// (paywall, bot protection, CORS on web) it falls back to feed content.
/// Loads the article page and returns whichever has more text (on a tie,
/// more blocks): the extracted page or the full content some feeds ship.
/// Never throws; on failure (paywall, bot protection, CORS on web) it falls
/// back to feed content.
///
/// With [acceptConsentWalls], a redirect to a consent page the
/// [ConsentService] knows is accepted once and the article fetched again.
@@ -81,7 +82,12 @@ class ArticleExtractor {
if (page.wallUrl != null) return ArticleContent(fromFeed.blocks, consentWall: true);
if (page.status != 200) return fromFeed;
final fromPage = parse(FeedService.decodeBody(page.bytes), base);
return fromPage.plainText.length > fromFeed.plainText.length ? fromPage : fromFeed;
final pageText = fromPage.plainText.length, feedText = fromFeed.plainText.length;
// Without text either way (a comic), the one with the picture wins.
return pageText > feedText ||
(pageText == feedText && fromPage.blocks.length > fromFeed.blocks.length)
? fromPage
: fromFeed;
} catch (_) {
return fromFeed;
}
@@ -119,7 +125,8 @@ class ArticleExtractor {
/// Class/id fragments of page furniture that sits inside article bodies.
static final _junkMarker = RegExp(
r'comment|related|newsletter|share|social|promo|advert|paywall|subscribe|'
r'recommend|author-box|byline|breadcrumb|tags|toolbar|consent|cookie|card|native-ad',
r'recommend|author-box|byline|breadcrumb|tags|toolbar|consent|cookie|card|native-ad|'
r'license',
caseSensitive: false,
);
@@ -155,14 +162,15 @@ class ArticleExtractor {
final blocks = <ReaderBlock>[];
final seenImages = <String>{};
var chars = 0;
void add(BlockType type, String text) {
if (chars > maxChars) return;
bool add(BlockType type, String text) {
if (chars > maxChars) return false;
if (type == BlockType.image) {
if (!seenImages.add(text)) return;
if (!seenImages.add(text)) return false;
} else {
chars += text.length;
}
blocks.add(ReaderBlock(type, text));
return true;
}
void walk(dom.Element el) {
@@ -190,6 +198,8 @@ class ArticleExtractor {
case 'blockquote':
final t = _clean(el.text);
if (t.isNotEmpty) add(BlockType.quote, t);
// Quoted screenshots, as in The Daily WTF's Error'd.
el.querySelectorAll('img').forEach((img) => _addImage(img, base, add));
return;
case 'pre':
final t = el.text.trimRight();
@@ -221,7 +231,7 @@ class ArticleExtractor {
static String _clean(String s) => s.replaceAll(RegExp(r'\s+'), ' ').trim();
static void _addImage(
dom.Element img, Uri base, void Function(BlockType, String) add) {
dom.Element img, Uri base, bool Function(BlockType, String) add) {
// Lazy-loading sites keep the real URL in data attributes.
final raw = img.attributes['data-src'] ??
img.attributes['data-lazy-src'] ??
@@ -232,19 +242,25 @@ class ArticleExtractor {
}
final width = int.tryParse(img.attributes['width'] ?? '');
if (width != null && width < 120) return; // icons, avatars, tracking pixels
if (img.attributes.containsKey('usemap')) return; // clickable navigation
// Images linking to another page are author avatars or related-story
// thumbnails; links to the full-size image file are fine.
// thumbnails; links to the full-size image file or within the page are fine.
for (var p = img.parent; p != null; p = p.parent) {
if (p.localName == 'a') {
final href = p.attributes['href'] ?? '';
if (!RegExp(r'\.(jpe?g|png|webp|gif|avif)(\?|$)', caseSensitive: false)
.hasMatch(href)) {
if (!href.startsWith('#') &&
!RegExp(r'\.(jpe?g|png|webp|gif|avif)(\?|$)', caseSensitive: false).hasMatch(href)) {
return;
}
break;
}
}
final url = base.resolve(raw).toString();
if (url.startsWith('http')) add(BlockType.image, url);
if (!url.startsWith('http') || !add(BlockType.image, url)) return;
// Comics such as xkcd put a last joke in the title text.
final title = _clean(img.attributes['title'] ?? '');
if (title.length >= 20 && title != _clean(img.attributes['alt'] ?? '')) {
add(BlockType.caption, title);
}
}
}
+16 -5
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../models/article.dart';
import '../models/news_source.dart';
import '../services/article_extractor.dart';
import '../services/consent_service.dart';
import '../services/content_blocking.dart';
@@ -275,12 +276,22 @@ class _ReaderView extends StatelessWidget {
builder: (context, snapshot) {
final loaded = snapshot.data;
var blocks = loaded?.blocks ?? const <ReaderBlock>[];
// Comics and memes are the picture: show it whole in the body
// rather than as a cropped banner.
final pictureLed = article.source.kind == SourceKind.fun;
final banner = pictureLed ? null : article.imageUrl;
// The page's own hero image duplicates the feed image shown on top.
if (article.imageUrl != null) {
if (banner != null) {
blocks = blocks.skipWhile((b) => b.type == BlockType.image).toList();
}
if (pictureLed &&
article.imageUrl != null &&
!blocks.any((b) => b.type == BlockType.image)) {
blocks = [ReaderBlock(BlockType.image, article.imageUrl!), ...blocks];
}
final isFull = loaded != null &&
loaded.plainText.length >= max(400, article.excerpt.length * 2);
(loaded.plainText.length >= max(400, article.excerpt.length * 2) ||
(pictureLed && blocks.any((b) => b.type == BlockType.image)));
// Skip the lead if the body already starts with it.
final firstText = blocks.where((b) => b.type == BlockType.paragraph).firstOrNull;
final showLead = article.excerpt.isNotEmpty &&
@@ -291,9 +302,9 @@ class _ReaderView extends StatelessWidget {
return ListView(
padding: const EdgeInsets.only(bottom: 48),
children: [
if (article.imageUrl != null)
if (banner != null)
Image.network(
article.imageUrl!,
banner,
fit: BoxFit.cover,
webHtmlElementStrategy: WebHtmlElementStrategy.prefer,
// Take up space only once the image has loaded, so a broken
@@ -336,7 +347,7 @@ class _ReaderView extends StatelessWidget {
),
if (isFull)
for (final block in blocks)
if (block.text != article.imageUrl) _BlockView(block: block),
if (block.text != banner) _BlockView(block: block),
if (loaded != null) ...[
const SizedBox(height: 24),
const Divider(),
+1 -1
View File
@@ -13,7 +13,7 @@ class LearnScreen extends StatelessWidget {
final AppState state;
/// The sections, top to bottom.
static const kinds = [SourceKind.learning];
static const kinds = [SourceKind.learning, SourceKind.fun];
@override
Widget build(BuildContext context) {