├── .gitignore
├── README.md
├── pubspec.yaml
├── LICENSE
├── CHANGELOG.md
├── test
└── favicon_test.dart
└── lib
└── favicon.dart
/.gitignore:
--------------------------------------------------------------------------------
1 | # Files and directories created by pub
2 | .dart_tool/
3 | .packages
4 | # Remove the following pattern if you wish to check in your lock file
5 | pubspec.lock
6 |
7 | # Conventional directory for build outputs
8 | build/
9 |
10 | # Directory created by dartdoc
11 | doc/api/
12 |
13 | .vscode
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Scrapes a website for favicons and orders them by image dimensions, or just return the best one. Example:
2 |
3 | ```dart
4 | import 'package:favicon/favicon.dart';
5 | var iconUrl = await FaviconFinder.getBest('https://www.mashable.com');
6 | print(iconUrl);
7 | ```
8 |
9 | Inspired by https://github.com/scottwernervt/favicon.
10 |
11 |
--------------------------------------------------------------------------------
/pubspec.yaml:
--------------------------------------------------------------------------------
1 | name: favicon
2 | description: Library for finding favicons in a website, either in a or by predefined URL.
3 | version: 1.1.2
4 | homepage: https://github.com/marcjoha/favicon
5 |
6 | environment:
7 | sdk: ">=3.0.0 <4.0.0"
8 |
9 | dependencies:
10 | http:
11 | html:
12 | image:
13 |
14 | dev_dependencies:
15 | test: ^1.22.2
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Marcus Johansson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # 1.1.2
2 |
3 | - Updated dependencies
4 |
5 | # 1.1.1
6 |
7 | - Updated dependencies
8 |
9 | # 1.1.0
10 |
11 | - Breaking change: Renaming classes to avoid conflicts with common Flutter libs
12 | - Favicon is now FaviconFinder
13 | - Icon is now Favicon
14 |
15 | # 1.0.15
16 |
17 | - Null-safety
18 |
19 | # 1.0.14
20 |
21 | - Updated dependencies
22 | - Added unit tests
23 |
24 | # 1.0.13
25 |
26 | - Better validation of ICO file signature, an ico can also contain a PNG.
27 | - Cleaned up code a bit.
28 |
29 | # 1.0.12
30 |
31 | - Validation of ICO file signaturee
32 |
33 | # 1.0.11
34 |
35 | - Support for filtering acceptable file extensions
36 | - Trims query string from URL
37 |
38 | # 1.0.10
39 |
40 | - Fix for NPE at content type check
41 |
42 | # 1.0.9
43 |
44 | - Always look for a valid HTTP status code, non-zero content and an image response header.
45 |
46 | # 1.0.8
47 |
48 | - Trims whitespace before and after URLs
49 |
50 | # 1.0.7
51 |
52 | - Double-check content type for predefined URLs
53 |
54 | # 1.0.6
55 |
56 | - Fix for URLs with just path
57 |
58 | # 1.0.5
59 |
60 | - Fix for failure on multiple link tags
61 | - Fix for NPE
62 |
63 | # 1.0.4
64 |
65 | - Will sort output based on image dimensions
66 | - Added a getBest method
67 |
68 | # 1.0.3
69 |
70 | - Don't rely on HTTP 200 OK for predefined URLs, but check file size too
71 |
72 | # 1.0.2
73 |
74 | - Fix for relative URLs
75 |
76 | # 1.0.1
77 |
78 | - Fixed broken example
79 |
80 | # 1.0.0
81 |
82 | - Initial version, created by Stagehand
83 |
--------------------------------------------------------------------------------
/test/favicon_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:favicon/favicon.dart';
2 | import 'package:test/test.dart';
3 |
4 | const FAVICONS = {
5 | 'https://www.aftonbladet.se':
6 | 'https://www.aftonbladet.se/cnp-assets/favicon-8447eb68/coast-228x228.png',
7 | 'https://www.braziltravelblog.com':
8 | 'https://www.braziltravelblog.com/wp-content/uploads/2021/04/cropped-brazil_small_flag-192x192.jpg',
9 | 'https://www.mashable.com':
10 | 'https://www.mashable.com/favicons/android-chrome-512x512.png',
11 | 'https://tradevenue.se/miljonarinnan30':
12 | 'https://cdn.32b23e.net/tradevenue/favicon.6567f653.png',
13 | 'https://github.com/': 'https://github.githubassets.com/favicons/favicon.svg',
14 | 'https://www.businessinsider.com/':
15 | 'https://www.businessinsider.com/public/assets/BI/US/favicons/favicon-32x32.png',
16 | 'https://www.mrmoneymustache.com/':
17 | 'https://www.mrmoneymustache.com/favicon.ico',
18 | 'https://thedividendstory.blogspot.com/':
19 | 'https://thedividendstory.blogspot.com/favicon.ico',
20 | 'http://dailybeast.com':
21 | 'http://dailybeast.com/static/media/favicon.b30a79ed.ico',
22 | 'https://www.tenable.com/blog-rss':
23 | 'https://www.tenable.com/themes/custom/tenable/img/favicons/favicon.svg',
24 | 'https://hbr.org/':
25 | 'https://hbr.org/resources/images/android-chrome-512x512.png',
26 | 'http://benfrain.com/': 'http://benfrain.com/favicon2.svg',
27 | 'http://www.alistapart.com':
28 | 'https://i0.wp.com/alistapart.com/wp-content/uploads/2019/03/cropped-icon_navigation-laurel-512.jpg',
29 | 'https://fortune.com/': 'https://fortune.com/icons/favicons/favicon.ico',
30 | 'https://tidochpengar.se/':
31 | 'https://tidochpengar.b-cdn.net/wp-content/uploads/2020/11/cropped-165DF740-708F-41B2-AFA0-C17794A0480E-192x192.jpeg',
32 | 'http://blog.tenablesecurity.com/atom.xml': null
33 | };
34 |
35 | void main() {
36 | for (var url in FAVICONS.keys) {
37 | test('Testing URL [$url]', () async {
38 | var icon = await FaviconFinder.getBest(url);
39 | expect(icon?.url, equals(FAVICONS[url]));
40 | });
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/lib/favicon.dart:
--------------------------------------------------------------------------------
1 | import 'dart:typed_data';
2 |
3 | import 'package:html/parser.dart';
4 | import 'package:http/http.dart' as http;
5 | import 'package:image/image.dart';
6 |
7 | // Signatures from https://en.wikipedia.org/wiki/List_of_file_signatures
8 | const ICO_SIG = [0, 0, 1, 0];
9 | const PNG_SIG = [137, 80, 78, 71, 13, 10, 26, 10];
10 |
11 | class Favicon implements Comparable {
12 | String url;
13 | int width;
14 | int height;
15 |
16 | Favicon(this.url, {this.width = 0, this.height = 0});
17 |
18 | @override
19 | int compareTo(Favicon other) {
20 | // If both are vector graphics, use URL length as tie-breaker
21 | if (url.endsWith('.svg') && other.url.endsWith('.svg')) {
22 | return url.length < other.url.length ? -1 : 1;
23 | }
24 |
25 | // Sort vector graphics before bitmaps
26 | if (url.endsWith('.svg')) return -1;
27 | if (other.url.endsWith('.svg')) return 1;
28 |
29 | // If bitmap size is the same, use URL length as tie-breaker
30 | if (width * height == other.width * other.height) {
31 | return url.length < other.url.length ? -1 : 1;
32 | }
33 |
34 | // Sort on bitmap size
35 | return (width * height > other.width * other.height) ? -1 : 1;
36 | }
37 |
38 | @override
39 | String toString() {
40 | return '{Url: $url, width: $width, height: $height}';
41 | }
42 | }
43 |
44 | class FaviconFinder {
45 | static Future> getAll(
46 | String url, {
47 | List? suffixes,
48 | }) async {
49 | var favicons = [];
50 | var iconUrls = [];
51 |
52 | var uri = Uri.parse(url);
53 | var document = parse((await http.get(uri)).body);
54 |
55 | // Look for icons in tags
56 | for (var rel in ['icon', 'shortcut icon']) {
57 | for (var iconTag in document.querySelectorAll("link[rel='$rel']")) {
58 | if (iconTag.attributes['href'] != null) {
59 | var iconUrl = iconTag.attributes['href']!.trim();
60 |
61 | // Fix scheme relative URLs
62 | if (iconUrl.startsWith('//')) {
63 | iconUrl = uri.scheme + ':' + iconUrl;
64 | }
65 |
66 | // Fix relative URLs
67 | if (iconUrl.startsWith('/')) {
68 | iconUrl = uri.scheme + '://' + uri.host + iconUrl;
69 | }
70 |
71 | // Fix naked URLs
72 | if (!iconUrl.startsWith('http')) {
73 | iconUrl = uri.scheme + '://' + uri.host + '/' + iconUrl;
74 | }
75 |
76 | // Remove query strings
77 | iconUrl = iconUrl.split('?').first;
78 |
79 | // Verify so the icon actually exists
80 | if (await _verifyImage(iconUrl)) {
81 | iconUrls.add(iconUrl);
82 | }
83 | }
84 | }
85 | }
86 |
87 | // Look for icon by predefined URL
88 | var iconUrl = uri.scheme + '://' + uri.host + '/favicon.ico';
89 | if (await _verifyImage(iconUrl)) {
90 | iconUrls.add(iconUrl);
91 | }
92 |
93 | // Deduplicate
94 | iconUrls = iconUrls.toSet().toList();
95 |
96 | // Filter on suffixes
97 | if (suffixes != null) {
98 | iconUrls.removeWhere((url) => !suffixes.contains(url.split('.').last));
99 | }
100 |
101 | // Fetch dimensions
102 | for (var iconUrl in iconUrls) {
103 | // No need for size calculation on vector images
104 | if (iconUrl.endsWith('.svg')) {
105 | favicons.add(Favicon(iconUrl));
106 | continue;
107 | }
108 |
109 | // Image library lacks read support for Ico, assume standard size
110 | // https://github.com/brendan-duncan/image/issues/212
111 | if (iconUrl.endsWith('.ico')) {
112 | favicons.add(Favicon(iconUrl, width: 16, height: 16));
113 | continue;
114 | }
115 |
116 | var image = decodeImage((await http.get(Uri.parse(iconUrl))).bodyBytes);
117 | if (image != null) {
118 | favicons
119 | .add(Favicon(iconUrl, width: image.width, height: image.height));
120 | }
121 | }
122 |
123 | return favicons..sort();
124 | }
125 |
126 | static Future getBest(String url, {List? suffixes}) async {
127 | List favicons = await getAll(url, suffixes: suffixes);
128 | return favicons.isNotEmpty ? favicons.first : null;
129 | }
130 |
131 | static Future _verifyImage(String url) async {
132 | var response = await http.get(Uri.parse(url));
133 |
134 | var contentType = response.headers['content-type'];
135 | if (contentType == null || !contentType.contains('image')) return false;
136 |
137 | // Take extra care with ico's since they might be constructed manually
138 | if (url.endsWith('.ico')) {
139 | if (response.bodyBytes.length < 4) return false;
140 |
141 | // Check if ico file contains a valid image signature
142 | if (!_verifySignature(response.bodyBytes, ICO_SIG) &&
143 | !_verifySignature(response.bodyBytes, PNG_SIG)) {
144 | return false;
145 | }
146 | }
147 |
148 | return response.statusCode == 200 &&
149 | (response.contentLength ?? 0) > 0 &&
150 | contentType.contains('image');
151 | }
152 |
153 | static bool _verifySignature(Uint8List bodyBytes, List signature) {
154 | var fileSignature = bodyBytes.sublist(0, signature.length);
155 | for (var i = 0; i < fileSignature.length; i++) {
156 | if (fileSignature[i] != signature[i]) return false;
157 | }
158 | return true;
159 | }
160 | }
161 |
--------------------------------------------------------------------------------