Add the Fun section to the Learn tab with comic-friendly reader view (#3)
The Learn tab gets a Fun section below Learning with the last 3 days of Fun articles. Fun articles stay out of Today and the Briefing. New curated Fun sources: xkcd and The Daily WTF (on by default) and r/ProgrammerHumor top of the day (off). Reader view for Fun articles shows the picture whole in the body instead of as a 16:9 banner cropped to fit, and doesn't call a comic without text a teaser. When the page gives no picture (Reddit blocks reader view), the feed's image is shown. The extractor now keeps an image's title text as a caption (xkcd's last joke), images in blockquotes and behind in-page anchors (The Daily WTF's Error'd screenshots), drops image maps and license boxes, and on a text tie prefers the page with more blocks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a6b8d8b537
commit
8240a767ef
10 files changed
+387
-20
No files matched your search
@@ -212,6 +212,37 @@ const defaultSources = <NewsSource>[
|
||||
description: 'System design explained with diagrams',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
|
||||
// --- 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user