├── .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 | --------------------------------------------------------------------------------