@@ -2,19 +2,27 @@ import 'dart:async';
22import 'dart:convert' ;
33import 'dart:io' ;
44
5+ import 'package:html/dom.dart' as html;
6+ import 'package:html/parser.dart' as html_parser;
57import 'package:http/http.dart' as http;
68import 'package:open_authenticator/app.dart' ;
79
810/// A logo search source.
911mixin Source {
1012 /// All logo sources.
1113 static const List <Source > sources = [
14+ DirectSource (),
1215 BrandfetchSource (),
1316 LogoDevSource (),
1417 UpLeadSource (),
1518 WikimediaSource (),
1619 ];
1720
21+ /// Headers to use when querying third-party services.
22+ static const Map <String , String > defaultHeaders = {
23+ HttpHeaders .userAgentHeader: 'OpenAuthenticator logo search (${App .githubRepositoryUrl })' ,
24+ };
25+
1826 /// The source name (to display attributions).
1927 String get name;
2028
@@ -24,7 +32,7 @@ mixin Source {
2432 /// Check whether the given [imageUrl] is good to display.
2533 static Future <bool > check (http.Client client, Uri imageUrl) async {
2634 try {
27- http.Response response = await client.head (imageUrl);
35+ http.Response response = await client.head (imageUrl, headers : defaultHeaders );
2836 return response.statusCode == HttpStatus .ok;
2937 } catch (_) {
3038 return false ;
@@ -48,6 +56,144 @@ mixin DirectApiSource on Source {
4856 };
4957}
5058
59+ /// Search directly on the target website.
60+ class DirectSource with Source {
61+ /// Creates a new direct source instance.
62+ const DirectSource ();
63+
64+ /// The meta image attributes to look for.
65+ static const List <String > _kMetaImageAttributes = [
66+ 'og:logo' ,
67+ 'og:image:secure_url' ,
68+ 'og:image:url' ,
69+ 'og:image' ,
70+ 'twitter:image:src' ,
71+ 'twitter:image' ,
72+ 'image' ,
73+ 'thumbnail' ,
74+ 'msapplication-tileimage' ,
75+ ];
76+
77+ /// The link image relations to look for.
78+ static const List <String > _kLinkImageRelations = [
79+ 'apple-touch-icon' ,
80+ 'apple-touch-icon-precomposed' ,
81+ 'icon' ,
82+ 'shortcut icon' ,
83+ 'mask-icon' ,
84+ ];
85+
86+ @override
87+ String get name => 'Direct' ;
88+
89+ @override
90+ Future <List <Uri >> _trySearch (http.Client client, String userKeywords) async {
91+ Set <Uri > result = {};
92+ for (Uri websiteUrl in _buildWebsiteUrls (userKeywords)) {
93+ try {
94+ http.Response response = await client.get (websiteUrl, headers: Source .defaultHeaders);
95+ if (response.statusCode != HttpStatus .ok || ! _isHtml (response)) {
96+ continue ;
97+ }
98+ result.addAll (_parseImageUrls (response.body, response.request? .url ?? websiteUrl));
99+ if (result.isNotEmpty) {
100+ break ;
101+ }
102+ } catch (_) {}
103+ }
104+ return result.toList ();
105+ }
106+
107+ /// Builds the website URLs to try.
108+ Iterable <Uri > _buildWebsiteUrls (String keywords) sync * {
109+ String trimmed = keywords.trim ();
110+ if (trimmed.isEmpty) {
111+ return ;
112+ }
113+
114+ Uri ? directUri = Uri .tryParse (trimmed);
115+ if (directUri? .hasScheme == true && directUri? .hasAuthority == true ) {
116+ yield directUri! ;
117+ return ;
118+ }
119+
120+ String hostLikeKeywords = trimmed.replaceFirst (RegExp (r'^www\.' , caseSensitive: false ), '' );
121+ if (! hostLikeKeywords.contains (' ' ) && hostLikeKeywords.contains ('.' )) {
122+ yield Uri .https (hostLikeKeywords, '' );
123+ yield Uri .https ('www.$hostLikeKeywords ' , '' );
124+ return ;
125+ }
126+
127+ String compactKeywords = trimmed.replaceAll (RegExp (r'\s+' ), '' );
128+ if (compactKeywords.isNotEmpty) {
129+ yield Uri .https ('$compactKeywords .com' , '' );
130+ yield Uri .https ('www.$compactKeywords .com' , '' );
131+ }
132+ }
133+
134+ /// Extracts image URLs from the HTML meta and link tags.
135+ Iterable <Uri > _parseImageUrls (String responseBody, Uri websiteUrl) {
136+ Map <String , Uri > metaImages = {};
137+ Map <String , Uri > linkImages = {};
138+ html.Document document = html_parser.parse (responseBody);
139+
140+ List <html.Element > elements = document.querySelectorAll ('meta, link' );
141+ for (html.Element element in elements) {
142+ String ? content = element.attributes['content' ];
143+ String ? href = element.attributes['href' ];
144+
145+ for (String key in [
146+ element.attributes['property' ],
147+ element.attributes['name' ],
148+ element.attributes['itemprop' ],
149+ ].nonNulls) {
150+ if (content == null ) {
151+ continue ;
152+ }
153+ Uri ? imageUrl = _buildImageUrl (content, websiteUrl);
154+ if (imageUrl != null ) {
155+ metaImages.putIfAbsent (key.toLowerCase (), () => imageUrl);
156+ }
157+ }
158+
159+ if (href != null ) {
160+ Set <String > relations = (element.attributes['rel' ] ?? '' ).toLowerCase ().split (RegExp (r'\s+' )).where ((relation) => relation.isNotEmpty).toSet ();
161+ for (String relation in _kLinkImageRelations) {
162+ if (relations.containsAll (relation.split (' ' ))) {
163+ Uri ? imageUrl = _buildImageUrl (href, websiteUrl);
164+ if (imageUrl != null ) {
165+ linkImages.putIfAbsent (relation, () => imageUrl);
166+ }
167+ }
168+ }
169+ }
170+ }
171+
172+ return [
173+ for (String attribute in _kMetaImageAttributes)
174+ if (metaImages[attribute] != null ) metaImages[attribute]! ,
175+ for (String relation in _kLinkImageRelations)
176+ if (linkImages[relation] != null ) linkImages[relation]! ,
177+ ];
178+ }
179+
180+ /// Checks whether the response is HTML.
181+ bool _isHtml (http.Response response) {
182+ String contentType = response.headers[HttpHeaders .contentTypeHeader] ?? '' ;
183+ return contentType.isEmpty || contentType.toLowerCase ().contains ('text/html' );
184+ }
185+
186+ /// Builds the image URL.
187+ Uri ? _buildImageUrl (String imageUrl, Uri websiteUrl) {
188+ try {
189+ Uri uri = websiteUrl.resolve (imageUrl.trim ());
190+ return uri.isScheme ('http' ) || uri.isScheme ('https' ) ? uri : null ;
191+ } catch (_) {
192+ return null ;
193+ }
194+ }
195+ }
196+
51197/// Search using Wikimedia.
52198class WikimediaSource with Source {
53199 /// Creates a new Wikimedia source instance.
@@ -60,7 +206,7 @@ class WikimediaSource with Source {
60206 Future <List <Uri >> _trySearch (http.Client client, String userKeywords) async {
61207 List <String > result = [];
62208 try {
63- http.Response response = await client.get (buildEndpointUrl (userKeywords));
209+ http.Response response = await client.get (buildEndpointUrl (userKeywords), headers : Source .defaultHeaders );
64210 Map <String , dynamic > json = jsonDecode (response.body);
65211 dynamic query = json['query' ];
66212 if (query is Map <String , dynamic >) {
0 commit comments