Hide paid Substack newsletter posts with the paid filter (#7)

Substack cuts paid-subscriber posts short in the feed and ends their
content with a "Read more" link to the post itself. PaywallService.isPaid
now recognizes that ending, so "hide paid articles" hides these posts in
Today and the Learn tab and counts them as hidden, without a page request.
Free posts that ask readers to subscribe or end with the subscribe widget
are not affected.

Tests use trimmed real items from the Interconnects and Ahead of AI feeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-16 15:57:48 +02:00
1 parent 4f035e4bb4
commit 84cb11e4dd
6 files changed
+139 -2

No files matched your search

+15 -1
View File
@@ -9,6 +9,8 @@ import 'storage.dart';
/// Recognizes paid and sponsored articles so the feed can hide them.
///
/// Most paid articles are marked in the title (heise+, Golem's "(g+)").
/// Substack newsletters cut paid posts short in the feed and end them with a
/// "Read more" link to the post itself, so those are recognized from the feed.
/// SPIEGEL and t3n only mark them in the page's schema.org data
/// (`"isAccessibleForFree": false`), so for those sources each new article is
/// checked once in the background and the result is remembered.
@@ -33,6 +35,14 @@ class PaywallService {
caseSensitive: false,
);
/// What Substack appends to a paid post's feed content in place of the rest:
/// `<p><a href="{post url}">Read more</a></p>`, as the very end. Free posts
/// end with their own text, even when they ask readers to subscribe.
static final _substackCut = RegExp(
r'<p>\s*<a href="([^"]+)">\s*Read more\s*</a>\s*</p>\s*$',
caseSensitive: false,
);
static final _marker =
RegExp(r'''"isAccessibleForFree"\s*:\s*"?(true|false)"?''', caseSensitive: false);
@@ -41,7 +51,11 @@ class PaywallService {
static const _concurrency = 6;
static const _keepFor = Duration(days: 14);
bool isPaid(Article a) => paidTitle.hasMatch(a.title) || (_checks[a.link]?.paid ?? false);
bool isPaid(Article a) =>
paidTitle.hasMatch(a.title) || _isCutSubstackPost(a) || (_checks[a.link]?.paid ?? false);
static bool _isCutSubstackPost(Article a) =>
_substackCut.firstMatch(a.contentHtml)?.group(1) == a.link;
bool isSponsored(Article a) => sponsoredTitle.hasMatch(a.title);