From 6108b24f21a7cd405c3df132695f008785e4d8dc Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Tue, 2 Jun 2026 12:02:52 +0200 Subject: [PATCH] improve reverse bang matching --- .../bangs/data/database/daos/bang.dart | 18 +++++--- .../bangs/domain/services/reverse_match.dart | 6 ++- apps/weblibre/lib/utils/uri_parser.dart | 31 ++++++++++++++ .../features/bangs/reverse_match_test.dart | 42 +++++++++++++++++++ 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/apps/weblibre/lib/features/bangs/data/database/daos/bang.dart b/apps/weblibre/lib/features/bangs/data/database/daos/bang.dart index 418a12d5..0248e515 100644 --- a/apps/weblibre/lib/features/bangs/data/database/daos/bang.dart +++ b/apps/weblibre/lib/features/bangs/data/database/daos/bang.dart @@ -26,6 +26,7 @@ import 'package:weblibre/features/bangs/data/models/bang.dart'; import 'package:weblibre/features/bangs/data/models/bang_data.dart'; import 'package:weblibre/features/bangs/data/models/bang_group.dart'; import 'package:weblibre/features/bangs/data/models/bang_key.dart'; +import 'package:weblibre/utils/uri_parser.dart'; @DriftAccessor() class BangDao extends DatabaseAccessor with $BangDaoMixin { @@ -129,14 +130,19 @@ class BangDao extends DatabaseAccessor with $BangDaoMixin { } Selectable getBangDataByTemplateHost(String host) { - final httpsPrefix = 'https://$host/%'; - final httpPrefix = 'http://$host/%'; + // Search engines redirect results between generic subdomains (e.g. + // bing.com → www.bing.com, www.youtube.com → m.youtube.com), so match the + // template against every equivalent host spelling, not just the live one. + final clauses = hostVariants(host) + .map( + (variant) => + db.bangDataView.urlTemplate.like('https://$variant/%') | + db.bangDataView.urlTemplate.like('http://$variant/%'), + ) + .toList(); return select(db.bangDataView) - ..where( - (t) => - t.urlTemplate.like(httpsPrefix) | t.urlTemplate.like(httpPrefix), - ); + ..where((_) => clauses.reduce((a, b) => a | b)); } Selectable queryBangs(String searchString) { diff --git a/apps/weblibre/lib/features/bangs/domain/services/reverse_match.dart b/apps/weblibre/lib/features/bangs/domain/services/reverse_match.dart index 4756db0f..8d9a4f41 100644 --- a/apps/weblibre/lib/features/bangs/domain/services/reverse_match.dart +++ b/apps/weblibre/lib/features/bangs/domain/services/reverse_match.dart @@ -92,7 +92,7 @@ class BangUrlPattern { /// Extracts the user query from [input] if it matches this pattern. Returns /// null otherwise. String? match(Uri input) { - if (input.host.toLowerCase() != host) return null; + if (uri_parser.normalizeHost(input.host) != host) return null; final mainInput = _componentsFromUri(input); if (mainInput == null) return null; @@ -178,7 +178,9 @@ class BangUrlPattern { if (!parsed.hasScheme || parsed.host.isEmpty) return null; if (parsed.host.contains(_sentinel)) return null; - final host = parsed.host.toLowerCase(); + // Normalize so a template host and a redirected results host that differ + // only by a generic subdomain (www./m.) still match. + final host = uri_parser.normalizeHost(parsed.host); final fragmentSlot = parsed.fragment.contains(_sentinel); final templateMain = _componentsFromUri(parsed); diff --git a/apps/weblibre/lib/utils/uri_parser.dart b/apps/weblibre/lib/utils/uri_parser.dart index 60046c1f..9c76ffd3 100644 --- a/apps/weblibre/lib/utils/uri_parser.dart +++ b/apps/weblibre/lib/utils/uri_parser.dart @@ -28,3 +28,34 @@ Uri? tryParseUrl(String? input, {bool eagerParsing = false}) { allowSchemelessHosts: eagerParsing, ); } + +/// Generic subdomain labels that don't identify a distinct site. Search engines +/// routinely redirect between these (e.g. `bing.com` → `www.bing.com`, +/// `www.youtube.com` → `m.youtube.com`), so they are treated as equivalent when +/// matching a live URL against a bang template host. +const _genericHostPrefixes = {'www', 'm', 'mobile'}; + +/// Strips a single leading generic subdomain label ([_genericHostPrefixes]) +/// from [host] so hosts differing only by such a prefix compare equal. +/// +/// `www.bing.com` and `bing.com` → `bing.com`; `m.youtube.com` → `youtube.com`. +/// Non-generic subdomains (e.g. `cn.bing.com`) are left untouched. +String normalizeHost(String host) { + final lower = host.toLowerCase(); + final dot = lower.indexOf('.'); + if (dot <= 0) return lower; + + final label = lower.substring(0, dot); + if (_genericHostPrefixes.contains(label)) { + return lower.substring(dot + 1); + } + return lower; +} + +/// All host spellings that should be considered the same site as [host] for the +/// purposes of bang template lookup: the normalized base plus each generic +/// subdomain variant. +Set hostVariants(String host) { + final base = normalizeHost(host); + return {base, for (final prefix in _genericHostPrefixes) '$prefix.$base'}; +} diff --git a/apps/weblibre/test/features/bangs/reverse_match_test.dart b/apps/weblibre/test/features/bangs/reverse_match_test.dart index 34994e6a..21570438 100644 --- a/apps/weblibre/test/features/bangs/reverse_match_test.dart +++ b/apps/weblibre/test/features/bangs/reverse_match_test.dart @@ -118,6 +118,48 @@ void main() { }); }); + group('BangUrlPattern - host normalization', () { + test('template without www matches www results host (Bing)', () { + final p = BangUrlPattern.parse('https://bing.com/search?q={{{s}}}'); + expect( + p!.match( + Uri.parse('https://www.bing.com/search?q=test&toWww=1&redig=ABC'), + ), + 'test', + ); + }); + + test('www template matches mobile results host (YouTube)', () { + final p = BangUrlPattern.parse( + 'https://www.youtube.com/results?search_query={{{s}}}', + ); + expect( + p!.match(Uri.parse('https://m.youtube.com/results?search_query=cats')), + 'cats', + ); + }); + + test('template without www matches www results host (Startpage)', () { + final p = BangUrlPattern.parse( + 'https://startpage.com/do/metasearch.pl?query={{{s}}}', + ); + expect( + p!.match( + Uri.parse('https://www.startpage.com/do/metasearch.pl?query=foo'), + ), + 'foo', + ); + }); + + test('non-generic subdomains still must match', () { + final p = BangUrlPattern.parse('https://cn.bing.com/dict/search?q={{{s}}}'); + expect( + p!.match(Uri.parse('https://www.bing.com/dict/search?q=foo')), + isNull, + ); + }); + }); + group('BangUrlPattern - fragment placeholder', () { test('hash query: 4chan-style #s={{{s}}}', () { final p = BangUrlPattern.parse(