├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci-phpstan.yml │ └── ci-tests.yml ├── .gitignore ├── .styleci.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRADE.md ├── composer.json ├── config └── favicon-fetcher.php ├── docs └── logo.png ├── phpstan.neon ├── phpunit.xml ├── src ├── Collections │ └── FaviconCollection.php ├── Concerns │ ├── BuildsCacheKeys.php │ ├── HasDefaultFunctionality.php │ ├── MakesHttpRequests.php │ └── ValidatesUrls.php ├── Contracts │ └── Fetcher.php ├── Drivers │ ├── FaviconGrabberDriver.php │ ├── FaviconKitDriver.php │ ├── GoogleSharedStuffDriver.php │ ├── HttpDriver.php │ └── UnavatarDriver.php ├── Exceptions │ ├── ConnectionException.php │ ├── FaviconFetcherException.php │ ├── FaviconNotFoundException.php │ ├── FeatureNotSupportedException.php │ ├── InvalidIconSizeException.php │ ├── InvalidIconTypeException.php │ └── InvalidUrlException.php ├── Facades │ └── Favicon.php ├── Favicon.php ├── FaviconFetcherProvider.php └── FetcherManager.php └── tests └── Feature ├── Collections └── FaviconCollectionTest.php ├── Concerns └── MakesHttpRequests │ ├── HttpClientTest.php │ └── WithRequestExceptionHandlingTest.php ├── Drivers ├── FaviconGrabberDriverTest.php ├── FaviconKitDriverTest.php ├── GoogleSharedStuffDriverTest.php ├── HttpDriverTest.php └── UnavatarDriverTest.php ├── FaviconTest.php ├── FetcherManagerTest.php ├── TestCase.php └── _data ├── CustomDriver.php └── NullDriver.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ash-jc-allen 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/ci-phpstan.yml: -------------------------------------------------------------------------------- 1 | name: run-phpstan 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | run-tests: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | php: [8.1, 8.2, 8.3, 8.4] 13 | laravel: [9.*, 10.*, 11.*, 12.*] 14 | include: 15 | - laravel: 12.* 16 | testbench: 10.* 17 | - laravel: 11.* 18 | testbench: 9.* 19 | - laravel: 10.* 20 | testbench: 8.* 21 | - laravel: 9.* 22 | testbench: 7.* 23 | exclude: 24 | - php: 8.1 25 | laravel: 11.* 26 | - php: 8.1 27 | laravel: 12.* 28 | 29 | name: PHP${{ matrix.php }} - Laravel ${{ matrix.laravel }} 30 | 31 | steps: 32 | - name: Update apt 33 | run: sudo apt-get update --fix-missing 34 | 35 | - name: Checkout code 36 | uses: actions/checkout@v2 37 | 38 | - name: Setup PHP 39 | uses: shivammathur/setup-php@v2 40 | with: 41 | php-version: ${{ matrix.php }} 42 | coverage: none 43 | 44 | - name: Setup Problem Matches 45 | run: | 46 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 47 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 48 | 49 | - name: Install dependencies 50 | run: | 51 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 52 | composer update --prefer-dist --no-interaction --no-suggest 53 | - name: Run Larastan 54 | run: composer larastan 55 | -------------------------------------------------------------------------------- /.github/workflows/ci-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | run-tests: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | php: [8.1, 8.2, 8.3, 8.4] 13 | laravel: [9.*, 10.*, 11.*, 12.*] 14 | include: 15 | - laravel: 12.* 16 | testbench: 10.* 17 | - laravel: 11.* 18 | testbench: 9.* 19 | - laravel: 10.* 20 | testbench: 8.* 21 | - laravel: 9.* 22 | testbench: 7.* 23 | exclude: 24 | - php: 8.1 25 | laravel: 11.* 26 | - php: 8.1 27 | laravel: 12.* 28 | 29 | name: PHP${{ matrix.php }} - Laravel ${{ matrix.laravel }} 30 | 31 | steps: 32 | - name: Update apt 33 | run: sudo apt-get update --fix-missing 34 | 35 | - name: Checkout code 36 | uses: actions/checkout@v2 37 | 38 | - name: Setup PHP 39 | uses: shivammathur/setup-php@v2 40 | with: 41 | php-version: ${{ matrix.php }} 42 | coverage: none 43 | 44 | - name: Setup Problem Matches 45 | run: | 46 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 47 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 48 | 49 | - name: Install dependencies 50 | run: | 51 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 52 | composer update --prefer-dist --no-interaction --no-suggest 53 | - name: Execute tests 54 | run: composer test 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | composer.lock 4 | .phpunit.result.cache -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | **v3.8.0 (released 2025-02-24):** 4 | 5 | - Added support for Laravel 12. ([#82](https://github.com/ash-jc-allen/favicon-fetcher/pull/82)) 6 | - Added support for PHPUnit 11. ([#83](https://github.com/ash-jc-allen/favicon-fetcher/pull/83)) 7 | - Migrated from `nunomaduro/larastan` to `larastan/larastan`. ([#84](https://github.com/ash-jc-allen/favicon-fetcher/pull/84)) 8 | - Added support for Larastan 3. ([#84](https://github.com/ash-jc-allen/favicon-fetcher/pull/84)) 9 | 10 | **v3.7.0 (released 2024-11-30):** 11 | 12 | - Added explicit nullable types to support PHP 8.4. ([#80](https://github.com/ash-jc-allen/favicon-fetcher/pull/80)) 13 | 14 | **v3.6.0 (released 2024-07-08):** 15 | 16 | - Added support for `symfony/dom-crawler` v7.0. ([#79](https://github.com/ash-jc-allen/favicon-fetcher/pull/79)) 17 | 18 | **v3.5.0 (released 2024-06-14):** 19 | 20 | - Added a new `verify_tls` config option to disable TLS certificate verification. ([#78](https://github.com/ash-jc-allen/favicon-fetcher/pull/78)) 21 | 22 | **v3.4.1 (released 2024-04-30):** 23 | 24 | - Fixed a bug that prevented fetching icons from a URL if the HTML contained a `link` tag without a `href` attribute.([#77](https://github.com/ash-jc-allen/favicon-fetcher/pull/77)) 25 | 26 | **v3.4.0 (released 2024-03-19):** 27 | 28 | - Added support for `nesbot/carbon 3.0`. ([#76](https://github.com/ash-jc-allen/favicon-fetcher/pull/76)) 29 | 30 | **v3.3.0 (released 2024-03-12):** 31 | 32 | - Added support for Laravel 11. ([#75](https://github.com/ash-jc-allen/favicon-fetcher/pull/75)) 33 | 34 | **v3.2.0 (released 2024-01-29):** 35 | 36 | - Added a `largestByFileSize` method to the `FaviconCollection`. [#73](https://github.com/ash-jc-allen/favicon-fetcher/pull/73) 37 | 38 | **v3.1.0 (released 2023-11-07):** 39 | 40 | - Added `user_agent` config field to configure HTTP `User-Agent` request header. ([#70](https://github.com/ash-jc-allen/favicon-fetcher/pull/70)) 41 | - Run CI tests using PHP 8.3 ([#69](https://github.com/ash-jc-allen/favicon-fetcher/pull/69)) 42 | 43 | **v3.0.0 (released 2023-09-04):** 44 | 45 | - Added `connect_timeout` and `timeout` config fields. ([#67](https://github.com/ash-jc-allen/favicon-fetcher/pull/67)) 46 | - Use `symfony/dom-crawler` in the `HttpDriver` to parse the HTML. ([#56](https://github.com/ash-jc-allen/favicon-fetcher/pull/56)) 47 | - Updated all files to use strict types for improved type safety. ([#62](https://github.com/ash-jc-allen/favicon-fetcher/pull/62)) 48 | - Throw package-specific exceptions instead of vendor exceptions. ([#67](https://github.com/ash-jc-allen/favicon-fetcher/pull/67)) 49 | - Fixed a bug that prevented an exception from being thrown when using `fetchAll` if no favicons were found when using the `throw` method. ([#56](https://github.com/ash-jc-allen/favicon-fetcher/pull/50)) 50 | - Fixed a bug that prevented the `fetchAll` method from trying to guess the default icon if no favicons were found. ([#56](https://github.com/ash-jc-allen/favicon-fetcher/pull/50)) 51 | - Fixed a bug that stripped the port from the base URL. Thanks for the fix, @mhoffmann777! ([#50](https://github.com/ash-jc-allen/favicon-fetcher/pull/50)) 52 | - Dropped support for PHP 8.0. ([#59](https://github.com/ash-jc-allen/favicon-fetcher/pull/59)) 53 | - Dropped support for Laravel 8. ([#59](https://github.com/ash-jc-allen/favicon-fetcher/pull/59)) 54 | - Dropped support for PHPUnit 8.* and Larastan 1.*. ([#59](https://github.com/ash-jc-allen/favicon-fetcher/pull/59)) 55 | 56 | **v2.0.0 (released 2023-03-23):** 57 | - Added driver for the [Favicon Grabber API](https://favicongrabber.com/). ([#24](https://github.com/ash-jc-allen/favicon-fetcher/pull/24)) 58 | - Added `fetchAll` implementation to the `HttpDriver` for fetching all the icons for a URL. ([#29](https://github.com/ash-jc-allen/favicon-fetcher/pull/29), [#31](https://github.com/ash-jc-allen/favicon-fetcher/pull/31)) 59 | - Added `fetchAll` method to the `AshAllenDesign\FaviconFetcher\Contracts\Fetcher` interface. ([#29](https://github.com/ash-jc-allen/favicon-fetcher/pull/29)) 60 | - Added support to get a favicons size and type. ([#29](https://github.com/ash-jc-allen/favicon-fetcher/pull/29), [#31](https://github.com/ash-jc-allen/favicon-fetcher/pull/31)) 61 | - Changed visibility of the `buildCacheKey` method in the `BuildsCacheKey` trait from `protected` to `public`. ([#31](https://github.com/ash-jc-allen/favicon-fetcher/pull/31)) 62 | - Changed the values that are used when caching a favicon. ([#31](https://github.com/ash-jc-allen/favicon-fetcher/pull/31)) 63 | - Removed the `makeFromCache` method from the `Favicon` class. ([#31](https://github.com/ash-jc-allen/favicon-fetcher/pull/31)) 64 | 65 | **v1.3.0 (released 2023-01-12):** 66 | - Added support for Laravel 10. (([#22](https://github.com/ash-jc-allen/favicon-fetcher/pull/22))) 67 | 68 | **v1.2.1 (released 2022-11-08):** 69 | - Fixed bug that prevented a favicon URL from being detected using the `HttpDriver` if the favicon URL was using single quotes (instead of double quotes). ([#20](https://github.com/ash-jc-allen/favicon-fetcher/pull/20)) 70 | 71 | **v1.2.0 (released 2022-10-17):** 72 | - Added support for PHP 8.2. ([#21](https://github.com/ash-jc-allen/favicon-fetcher/pull/21)) 73 | 74 | **v1.1.3 (released 2022-09-03):** 75 | - Removed an incorrect mime type from the file extension detection. ([#19](https://github.com/ash-jc-allen/favicon-fetcher/pull/19)) 76 | 77 | **v1.1.2 (released 2022-07-23):** 78 | - Fixed bug that was using the incorrect file extension when storing favicons retrieved using the "google-shared-stuff", "unavatar", and "favicon-kit" drivers. ([#17](https://github.com/ash-jc-allen/favicon-fetcher/pull/17)) 79 | 80 | **v1.1.1 (released 2022-05-10):** 81 | - Fixed bug that was returning the incorrect favicon URL in the `HttpDriver` if multiple `` elements existed on the same line in the webpage's HTML. ([#13](https://github.com/ash-jc-allen/favicon-fetcher/pull/13)) 82 | 83 | **v1.1.0 (released 2022-04-27):** 84 | - Added driver for [Unavatar](https://unavatar). ([#8](https://github.com/ash-jc-allen/favicon-fetcher/pull/8)) 85 | 86 | **v1.0.0 (released 2022-04-26):** 87 | - Initial release. 88 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ashley Allen 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Favicon Fetcher 3 |

4 | 5 |

6 | Latest Version on Packagist 7 | Total Downloads 8 | PHP from Packagist 9 | GitHub license 10 |

11 | 12 | ## Table of Contents 13 | 14 | - [Overview](#overview) 15 | - [Installation](#installation) 16 | * [Requirements](#requirements) 17 | * [Install the Package](#install-the-package) 18 | * [Publish the Config](#publish-the-config) 19 | - [Usage](#usage) 20 | * [Fetching Favicons](#fetching-favicons) 21 | + [Using the `fetch` Method](#using-the-fetch-method) 22 | + [Using the `fetchOr` Method](#using-the-fetchor-method) 23 | + [Using the `fetchAll` Method](#using-the-fetchall-method) 24 | + [Using the `fetchAllOr` Method](#using-the-fetchallor-method) 25 | * [Exceptions](#exceptions) 26 | * [Drivers](#drivers) 27 | * [Available Drivers](#available-drivers) 28 | + [How to Choose a Driver](#how-to-choose-a-driver) 29 | * [Choosing a Driver](#choosing-a-driver) 30 | + [Fallback Drivers](#fallback-drivers) 31 | + [Adding Your Own Driver](#adding-your-own-driver) 32 | * [HTTP Timeouts](#http-timeouts) 33 | * [TLS Verification](#tls-verification) 34 | * [HTTP User Agent](#http-user-agent) 35 | * [Storing Favicons](#storing-favicons) 36 | + [Using `store`](#using-store) 37 | + [Using `storeAs`](#using-storeas) 38 | * [Caching Favicons](#caching-favicons) 39 | * [Favicon Types](#favicon-types) 40 | * [Favicon Sizes](#favicon-sizes) 41 | - [Testing](#testing) 42 | - [Security](#security) 43 | - [Contribution](#contribution) 44 | - [Changelog](#changelog) 45 | - [Upgrading](#upgrading) 46 | - [Credits](#credits) 47 | - [License](#license) 48 | 49 | ## Overview 50 | 51 | A Laravel package that can be used for fetching favicons from websites. 52 | 53 | ## Installation 54 | 55 | ### Requirements 56 | 57 | The package has been developed and tested to work with the following minimum requirements: 58 | 59 | - PHP 8.0 60 | - Laravel 8.0 61 | 62 | ### Install the Package 63 | 64 | You can install the package via Composer: 65 | 66 | ```bash 67 | composer require ashallendesign/favicon-fetcher 68 | ``` 69 | 70 | ### Publish the Config 71 | You can then publish the package's config file by using the following command: 72 | 73 | ```bash 74 | php artisan vendor:publish --provider="AshAllenDesign\FaviconFetcher\FaviconFetcherProvider" 75 | ``` 76 | 77 | ## Usage 78 | 79 | ### Fetching Favicons 80 | 81 | Now that you have the package installed, you can start fetching the favicons from different websites. 82 | 83 | #### Using the `fetch` Method 84 | 85 | To fetch a favicon from a website, you can use the `fetch` method which will return an instance of `AshAllenDesign\FaviconFetcher\Favicon`: 86 | 87 | ```php 88 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 89 | 90 | $favicon = Favicon::fetch('https://ashallendesign.co.uk'); 91 | ``` 92 | 93 | #### Using the `fetchOr` Method 94 | 95 | If you'd like to provide a default value to be used if a favicon cannot be found, you can use the `fetchOr` method. 96 | 97 | For example, if you wanted to use a default icon (`https://example.com/favicon.ico`) if a favicon could not be found, your code could look something like this: 98 | 99 | ```php 100 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 101 | 102 | $favicon = Favicon::fetchOr('https://ashallendesign.co.uk', 'https://example.com/favicon.ico'); 103 | ``` 104 | 105 | This method also accepts a `Closure` as the second argument if you'd prefer to run some custom logic. The `url` field passed as the first argument to the `fetchOr` method is available to use in the closure. For example, to use a closure, your code could look something like this: 106 | 107 | ```php 108 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 109 | 110 | $favicon = Favicon::fetchOr('https://ashallendesign.co.uk', function ($url) { 111 | // Run extra logic here... 112 | 113 | return 'https://example.com/favicon.ico'; 114 | }); 115 | ``` 116 | 117 | #### Using the `fetchAll` Method 118 | 119 | There may be times when you want to retrieve the different sized favicons for a given website. To get the different sized favicons, you can use the `fetchAll` method which will return an instance of `AshAllenDesign\FaviconFetcher\Collections\FaviconCollection`. This collection contains instances of `AshAllenDesign\FaviconFetcher\Favicon`. For example, to get all the favicons for a site, you can use the `fetchAll` method like so: 120 | 121 | ```php 122 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 123 | 124 | $favicons = Favicon::fetchAll('https://ashallendesign.co.uk'); 125 | ``` 126 | 127 | The `FaviconCollection` class extends the `Illuminate\Support\Collection` class, so you can use all the methods available on the `Collection` class. 128 | 129 | It also includes a `largest` method that you can use to get the favicon with the largest dimensions. It's worth noting that if the size of the favicon is unknown, it will be treated as if it has a size of `0x0px` when determining which is the largest. For example, you can use the `largest` method like this: 130 | 131 | ```php 132 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 133 | 134 | $largestFavicon = Favicon::fetchAll('https://ashallendesign.co.uk')->largest(); 135 | ``` 136 | 137 | The `FaviconCollection` also provides a `largestByFileSize` method that you can use to get the favicon with the largest file size. You may want to do this if the package cannot detect the sizes of the icons for a given website, and so it can't detect the largest icon. This method works based on the assumption that the larger the file size, the larger the image dimensions. For example, you can use the `largestByFileSize` method like this: 138 | 139 | ```php 140 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 141 | 142 | $largestFavicon = Favicon::fetchAll('https://ashallendesign.co.uk')->largestByFileSize(); 143 | ``` 144 | 145 | Note: Only the `http` driver supports retrieving all the favicons for a given website. For this reason, the `fetchAll` method does not support fallbacks. Support may be added for other drivers and fallbacks in the future. 146 | 147 | #### Using the `fetchAllOr` Method 148 | 149 | If you'd like to provide a default value to be used if all the favicons for a site cannot be found, you can use the `fetchAllOr` method. 150 | 151 | For example, if you wanted to use a default icon (`https://example.com/favicon.ico`) if the favicons could not be found, your code could look something like this: 152 | 153 | ```php 154 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 155 | 156 | $favicon = Favicon::fetchAllOr('https://ashallendesign.co.uk', 'https://example.com/favicon.ico'); 157 | ``` 158 | 159 | This method also accepts a `Closure` as the second argument if you'd prefer to run some custom logic. The `url` field passed as the first argument to the `fetchAllOr` method is available to use in the closure. For example, to use a closure, your code could look something like this: 160 | 161 | ```php 162 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 163 | 164 | $favicon = Favicon::fetchAllOr('https://ashallendesign.co.uk', function ($url) { 165 | // Run extra logic here... 166 | 167 | return 'https://example.com/favicon.ico'; 168 | }); 169 | ``` 170 | 171 | ### Exceptions 172 | 173 | By default, if a favicon can't be found for a URL, the `fetch` method will return `null`. However, if you'd prefer an exception to be thrown, you can use the `throw` method available on the `Favicon` facade. This means that if a favicon can't be found, an `AshAllenDesign\FaviconFetcher\Exceptions\FaviconNotFoundException` will be thrown. 174 | 175 | To enable exceptions to be thrown, your code could look something like this: 176 | 177 | ```php 178 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 179 | 180 | $favicon = Favicon::throw()->fetch('https://ashallendesign.co.uk'); 181 | ``` 182 | 183 | If you attempt to fetch a favicon and the request times out or no website is found at the URL, an `AshAllenDesign\FaviconFetcher\Exceptions\ConnectionException` will be thrown. This will be thrown even if the `throw` method has not been used. 184 | 185 | ### Drivers 186 | 187 | Favicon Fetcher provides the functionality to use different drivers for retrieving favicons from websites. 188 | 189 | ### Available Drivers 190 | 191 | By default, Favicon Fetcher ships with 5 drivers out-the-box: `http`, `google-shared-stuff`, `favicon-kit`, `unavatar`, `favicon-grabber`. 192 | 193 | The `http` driver fetches favicons by attempting to parse "icon" and "shortcut icon" link elements from the returned HTML of a webpage. If it can't find one, it will attempt to guess the URL of the favicon based on common defaults. 194 | 195 | The `google-shared-stuff` driver fetches favicons using the [Google Shared Stuff](https://google.com) API. 196 | 197 | The `favicon-kit` driver fetches favicons using the [Favicon Kit](https://faviconkit.com) API. 198 | 199 | The `unavatar` driver fetches favicons using the [Unavatar](https://unavatar.io) API. 200 | 201 | The `favicon-grabber` driver fetches favicons using the [Favicon Grabber](https://favicongrabber.com) API. 202 | 203 | #### How to Choose a Driver 204 | 205 | It's important to remember that the `google-shared-stuff`, `favicon-kit`, and `unavatar` drivers interact with third-party APIs to retrieve the favicons. So, this means that some data will be shared to external services. 206 | 207 | However, the `http` driver does not use any external services and directly queries the website that you are trying to fetch the favicon for. Due to the fact that this package is new, it is likely that the `http` driver may not be 100% accurate when trying to fetch favicons from websites. So, theoretically, the `http` driver should provide you with better privacy, but may not be as accurate as the other drivers. 208 | 209 | ### Choosing a Driver 210 | 211 | You can select which driver to use by default by changing the `default` field in the `favicon-fetcher` config file after you've published it. The package originally ships with the `http` driver enabled as the default driver. 212 | 213 | For example, if you wanted to change your default driver to `favicon-kit`, you could update your `favicon-fetcher` config like so: 214 | 215 | ```php 216 | return [ 217 | 218 | // ... 219 | 220 | 'default' => 'favicon-kit', 221 | 222 | // ... 223 | 224 | ] 225 | ``` 226 | 227 | If you'd like to set the driver on-the-fly, you can do so by using the `driver` method on the `Favicon` facade. For example, if you wanted to use the `google-shared-stuff` driver, you could do so like this: 228 | 229 | ```php 230 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 231 | 232 | $favicon = Favicon::driver('google-shared-stuff')->fetch('https://ashallendesign.co.uk'); 233 | ``` 234 | 235 | #### Fallback Drivers 236 | 237 | There may be times when a particular driver cannot find a favicon for a website. If this happens, you can fall back and attempt to find it again using a different driver. 238 | 239 | For example, if we wanted to try and fetch the favicon using the `http` driver and then fall back to the `google-shared-stuff` driver if we can't find it, your code could look something like this: 240 | 241 | ```php 242 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 243 | 244 | $favicon = Favicon::withFallback('google-shared-stuff')->fetch('https://ashallendesign.co.uk'); 245 | ``` 246 | 247 | #### Adding Your Own Driver 248 | 249 | There might be times when you want to provide your own custom logic for fetching favicons. To do this, you can build your driver and register it with the package for using. 250 | 251 | First, you'll need to create your own class and make sure that it implements the `AshAllenDesign\FaviconFetcher\Contracts\Fetcher` interface. For example, your class could like this: 252 | 253 | ```php 254 | use AshAllenDesign\FaviconFetcher\Contracts\Fetcher; 255 | use AshAllenDesign\FaviconFetcher\Favicon; 256 | 257 | class MyCustomDriver implements Fetcher 258 | { 259 | public function fetch(string $url): ?Favicon 260 | { 261 | // Add logic here that attempts to fetch a favicon... 262 | } 263 | 264 | public function fetchOr(string $url, mixed $default): mixed 265 | { 266 | // Add logic here that attempts to fetch a favicon or return a default... 267 | } 268 | } 269 | ``` 270 | 271 | After you've created your new driver, you'll be able to register it with the package using the `extend` method available through the `Favicon` facade. You may want to do this in a service provider so that it is set up and available in the rest of your application. 272 | 273 | You can register your custom driver like so: 274 | 275 | ```php 276 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 277 | 278 | Favicon::extend('my-custom-driver', new MyCustomDriver()); 279 | ``` 280 | 281 | Now that you've registered your custom driver, you'll be able to use it for fetching favicons like so: 282 | 283 | ```php 284 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 285 | 286 | $favicon = Favicon::driver('my-custom-driver')->fetch('https://ashallendesign.co.uk'); 287 | ``` 288 | 289 | ### HTTP Timeouts 290 | 291 | Favicon Fetcher provides the ability for you to set the connection timeout and request timeout for all the drivers. 292 | 293 | The connection timeout is the time that the package will wait for a connection to be made to the website. The request timeout is the time that the package will wait for the website to respond to the request. 294 | 295 | To do this, you can update the `connect_timeout` and `timeout` fields in the `favicon-fetcher.php` config file after you've published it. For example, to set the connection timeout to 5 seconds and the request timeout to 10 seconds, you could update your config file like so: 296 | 297 | ```php 298 | return [ 299 | 300 | // ... 301 | 302 | 'connect_timeout' => 5, 303 | 304 | 'timeout' => 10, 305 | 306 | // ... 307 | 308 | ] 309 | ``` 310 | 311 | If you'd prefer that no timeout be set, you can set the values to `0`. 312 | 313 | Please note that these timeouts are applied to all HTTP requests that Favicon Fetcher makes, regardless of the driver that is being used. 314 | 315 | ### TLS Verification 316 | 317 | Favicon Fetcher uses TLS verification by default, but this can be disabled. This can be useful in development environments or situations where you might be working with self-signed certificates or certificates from an untrusted certificate authority. 318 | 319 | You can disable the verification by updating the `verify_tls` field in the `favicon-fetcher.php` config file after you've published it. 320 | 321 | ```php 322 | return [ 323 | 324 | // ... 325 | 326 | 'verify_tls' => false, 327 | 328 | // ... 329 | 330 | ] 331 | ``` 332 | 333 | Or by updating your `.env` file: 334 | 335 | ```dotenv 336 | FAVICON_FETCHER_VERIFY_TLS=false 337 | ``` 338 | 339 | ### HTTP User Agent 340 | 341 | You may find that your requests are sometimes blocked by websites when trying to retrieve a favicon. This may be due to the fact that the default Guzzle `User-Agent` header is passed in the requests. 342 | 343 | Favicon Fetcher allows you to set the `User-Agent` header that is used in the package's requests. To do this, you can update the `user_agent` field in the `favicon-fetcher.php` config file after you've published it. For example, to set the `User-Agent` header to `My Custom User Agent`, you could update your config file like so: 344 | 345 | ```php 346 | return [ 347 | 348 | // ... 349 | 350 | 'user_agent' => 'My Custom User Agent', 351 | 352 | // ... 353 | 354 | ] 355 | ``` 356 | 357 | The `User-Agent` header will be set on all HTTP requests that Favicon Fetcher makes, regardless of the driver that is being used. 358 | 359 | The `user_agent` config field is already configured in the config file to read directly from a `FAVICON_FETCHER_USER_AGENT` field in your `.env` file. So, if you'd prefer to set the `User-Agent` header in your `.env` file, you could do so like this: 360 | 361 | ```dotenv 362 | FAVICON_FETCHER_USER_AGENT="My Custom User Agent" 363 | ``` 364 | 365 | ### Storing Favicons 366 | 367 | After fetching favicons, you might want to store them in your filesystem so that you don't need to fetch them again in the future. Favicon Fetcher provides two methods that you can use for storing the favicons: `store` and `storeAs`. 368 | 369 | #### Using `store` 370 | 371 | If you use the `store` method, a filename will automatically be generated for the favicon before storing. The method's first parameter accepts a string and is the directory that the favicon will be stored in. You can store a favicon using your default filesystem disk like so: 372 | 373 | ```php 374 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 375 | 376 | $faviconPath = Favicon::fetch('https://ashallendesign.co.uk')->store('favicons'); 377 | 378 | // $faviconPath is now equal to: "/favicons/abc-123.ico" 379 | ``` 380 | 381 | If you'd like to use a different storage disk, you can pass it as an optional second argument to the `store` method. For example, to store the favicon on S3, your code use the following: 382 | 383 | ```php 384 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 385 | 386 | $faviconPath = Favicon::fetch('https://ashallendesign.co.uk')->store('favicons', 's3'); 387 | 388 | // $faviconPath is now equal to: "/favicons/abc-123.ico" 389 | ``` 390 | 391 | #### Using `storeAs` 392 | 393 | If you use the `storeAs` method, you will be able to define the filename that the file will be stored as. The method's first parameter accepts a string and is the directory that the favicon will be stored in. The second parameter specifies the favicon filename (excluding the file extension). You can store a favicon using your default filesystem disk like so: 394 | 395 | ```php 396 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 397 | 398 | $faviconPath = Favicon::fetch('https://ashallendesign.co.uk')->storeAs('favicons', 'ashallendesign'); 399 | 400 | // $faviconPath is now equal to: "/favicons/ashallendesign.ico" 401 | ``` 402 | 403 | If you'd like to use a different storage disk, you can pass it as an optional third argument to the `storeAs` method. For example, to store the favicon on S3, your code use the following: 404 | 405 | ```php 406 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 407 | 408 | $faviconPath = Favicon::fetch('https://ashallendesign.co.uk')->storeAs('favicons', 'ashallendesign', 's3'); 409 | 410 | // $faviconPath is now equal to: "/favicons/ashallendesign.ico" 411 | ``` 412 | 413 | ### Caching Favicons 414 | 415 | As well as being able to store favicons, the package also allows you to cache the favicon URLs. This can be extremely useful if you don't want to store a local copy of the file and want to use the external version of the favicon that the website uses. 416 | 417 | As a basic example, if you have a page displaying 50 websites and their favicons, we would need to find the favicon's URL on each page load. As can imagine, this would drastically increase the page load time. So, by retrieving the URLs from the cache, it would majorly improve up the page speed. 418 | 419 | To cache a favicon, you can use the `cache` method available on the `Favicon` class. The first parameter accepts a `Carbon\CarbonInterface` as the cache lifetime. For example, to cache the favicon URL of `https://ashallendesign.co.uk` for 1 day, your code might look something like: 420 | 421 | ```php 422 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 423 | 424 | $favicon = Favicon::fetch('https://ashallendesign.co.uk')->cache(now()->addDay()); 425 | ``` 426 | 427 | By default, the package will always try and resolve the favicon from the cache before attempting to retrieve a fresh version. However, if you want to disable the cache and always retrieve a fresh version, you can use the `useCache` method like so: 428 | 429 | ```php 430 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 431 | 432 | $favicon = Favicon::useCache(false)->fetch('https://ashallendesign.co.uk'); 433 | ``` 434 | 435 | The package uses `favicon-fetcher` as a prefix for all the cache keys. If you'd like to change this, you can do so by changing the `cache.prefix` field in the `favicon-fethcher` config file. For example, to change the prefix to `my-awesome-prefix`, you could update your config file like so: 436 | 437 | ```php 438 | return [ 439 | 440 | // ... 441 | 442 | 'cache' => [ 443 | 'prefix' => 'my-awesome-prefix', 444 | ] 445 | 446 | // ... 447 | 448 | ] 449 | ``` 450 | 451 | The package also provides the functionality for you to cache collections of favicons that have been retrieved using the `fetchAll` method. You can do this by calling the `cache` method on the `FaviconCollection` class like so: 452 | 453 | ```php 454 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 455 | 456 | $faviconCollection = Favicon::fetchAll('https://ashallendesign.co.uk')->cache(now()->addDay()); 457 | ``` 458 | 459 | ### Favicon Types 460 | 461 | When attempting to retrieve favicons using the `http` driver, we may be able to determine the favicons' type (such as `icon`, `shortcut icon`, or `apple-touch-icon`). To get the type of the favicon, you can use the `getIconType` method like so: 462 | 463 | ```php 464 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 465 | 466 | $faviconPath = Favicon::fetch('https://ashallendesign.co.uk')->getIconType(); 467 | ``` 468 | 469 | This method can return one of four constants defined on the `Favicon` class: `TYPE_ICON`, `TYPE_SHORTCUT_ICON`, `TYPE_APPLE_TOUCH_ICON`, and `TYPE_ICON_UNKNOWN`. 470 | 471 | You can make use of these constants for things like filtering. For example, if you wanted to get all the icons except the `apple-touch-icon`, you could do the following: 472 | 473 | ```php 474 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 475 | 476 | $faviconCollection = Favicon::fetchAll('https://ashallendesign.co.uk'); 477 | 478 | $faviconCollection->filter(function ($favicon) { 479 | return $favicon->getIconType() !== Favicon::TYPE_APPLE_TOUCH_ICON; 480 | }); 481 | ``` 482 | 483 | ### Favicon Sizes 484 | 485 | When attempting to retrieve favicons using the `http` driver, we may be able to determine the favicons' sizes. To get the size of the favicon, you can use the `getIconSize` method like so: 486 | 487 | ```php 488 | use AshAllenDesign\FaviconFetcher\Facades\Favicon; 489 | 490 | $faviconSize = Favicon::fetch('https://ashallendesign.co.uk')->getIconSize(); 491 | ``` 492 | 493 | It's assumed that the icons are square, so only a single integer will be returned. For example, if a favicon is 16x16px, then the `getIconSize` method will return `16`. If the size is unknown, `null` will be returned. 494 | 495 | ## Testing 496 | 497 | To run the package's unit tests, run the following command: 498 | 499 | ``` bash 500 | composer test 501 | ``` 502 | 503 | To run Larastan for the package, run the following command: 504 | 505 | ```bash 506 | composer larastan 507 | ``` 508 | 509 | ## Security 510 | 511 | If you find any security related issues, please contact me directly at [mail@ashallendesign.co.uk](mailto:mail@ashallendesign.co.uk) to report it. 512 | 513 | ## Contribution 514 | 515 | If you wish to make any changes or improvements to the package, feel free to make a pull request. 516 | 517 | To contribute to this package, please use the following guidelines before submitting your pull request: 518 | 519 | - Write tests for any new functions that are added. If you are updating existing code, make sure that the existing tests 520 | pass and write more if needed. 521 | - Follow [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standards. 522 | - Make all pull requests to the `master` branch. 523 | 524 | ## Changelog 525 | 526 | Check the [CHANGELOG](CHANGELOG.md) to get more information about the latest changes. 527 | 528 | ## Upgrading 529 | 530 | Check the [UPGRADE](UPGRADE.md) guide to get more information on how to update this library to newer versions. 531 | 532 | ## Credits 533 | 534 | - [Ash Allen](https://ashallendesign.co.uk) 535 | - [Jess Pickup](https://jesspickup.co.uk) (Logo) 536 | - [All Contributors](https://github.com/ash-jc-allen/short-url/graphs/contributors) 537 | 538 | ## License 539 | 540 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 541 | 542 | ## Support Me 543 | 544 | If you've found this package useful, please consider buying a copy of [Battle Ready Laravel](https://battle-ready-laravel.com) to support me and my work. 545 | 546 | Every sale makes a huge difference to me and allows me to spend more time working on open-source projects and tutorials. 547 | 548 | To say a huge thanks, you can use the code **BATTLE20** to get a 20% discount on the book. 549 | 550 | [👉 Get Your Copy!](https://battle-ready-laravel.com) 551 | 552 | [![Battle Ready Laravel](https://ashallendesign.co.uk/images/custom/sponsors/battle-ready-laravel-horizontal-banner.png)](https://battle-ready-laravel.com) 553 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## Contents 4 | 5 | - [Upgrading from 2.* to 3.0.0](#upgrading-from-2-to-300) 6 | - [Upgrading from 1.* to 2.0.0](#upgrading-from-1-to-200) 7 | 8 | ## Upgrading from 2.* to 3.0.0 9 | 10 | ### Exceptions 11 | 12 | Previously, if Favicon Fetcher attempted to make a request to a URL and the request failed (for example, if the site doesn't exist), an `Illuminate\Http\Client\ConnectionException` would be thrown. However, as of v3.0.0, a `AshAllenDesign\FaviconFetcher\Exceptions\ConnectionException` will be thrown instead. 13 | 14 | This will be thrown in the following situations: 15 | 16 | - If the URL has a valid structure but the site doesn't exist. 17 | - If the HTTP client exceeds the connection timeout. 18 | - If the HTTP client exceeds the request timeout. 19 | 20 | ## Upgrading from 1.* to 2.0.0 21 | 22 | ### Method Visibility Changes 23 | 24 | The visibility of the `buildCacheKey` method in the `AshAllenDesign\FaviconFetcher\Concerns\BuildsCacheKeys` trait has been changed from `protected` to `public`. If you are overriding this method anywhere in your code, you'll need to update the visibility to `public`. 25 | 26 | ### Added `fetchAll` and `fetchAllOr` Methods to `Fetcher` Interface 27 | 28 | The `fetchAll` and `fetchAllOr` methods have been added to the `AshAllenDesign\FaviconFetcher\Interfaces\Fetcher` interface. If you are implementing this interface in your own code, you'll need to add these method to your implementation. 29 | 30 | The signatures for the new methods are: 31 | 32 | ```php 33 | public function fetchAll(string $url): FaviconCollection; 34 | ``` 35 | 36 | ```php 37 | public function fetchAllOr(string $url, mixed $default): mixed; 38 | ``` 39 | 40 | ### Removed `makeFromCache` Method from `Favicon` Class 41 | 42 | The `makeFromCache` method in the `AshAllenDesign\FaviconFetcher\Favicon` class has been removed. This method was originally intended as a helper method when first added, but it doesn't provide much value, so it has been removed. 43 | 44 | If you were making use of this method anywhere, you'll need to remove it from your code. 45 | 46 | ### Caching Changes 47 | 48 | Previously, Favicon Fetcher only stored the URL of the favicon when calling the `cache` method. However, as of v2.0.0, Favicon Fetcher can determine the size and type of favicons, so this information is now stored in the cache as well. 49 | 50 | This means that instead of a string being stored in the cache, an array is now stored instead. 51 | 52 | The package has some minor backwards-compatible support to handle items cached before v2.0.0. If you are attempting to retrieve a cached favicon that was stored in the cache before v2.0.0, the `Favicon` class' type and size won't be set. The size and type will only be available on Favicons that were cached from v2.0.0 onwards. 53 | 54 | In a future release (likely v3.0.0), the backwards-compatible support will be removed so that only arrays can be read from the cache. 55 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ashallendesign/favicon-fetcher", 3 | "description": "A Laravel package for fetching website's favicons.", 4 | "type": "library", 5 | "homepage": "https://github.com/ash-jc-allen/favicon-fetcher", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Ash Allen", 10 | "email": "mail@ashallendesign.co.uk" 11 | } 12 | ], 13 | "keywords": [ 14 | "ashallendesign", 15 | "favicon-fetcher", 16 | "favicon", 17 | "icon" 18 | ], 19 | "require": { 20 | "php": "^8.1", 21 | "nesbot/carbon": "^2.0|^3.0", 22 | "illuminate/cache": "^9.0|^10.0|^11.0|^12.0", 23 | "illuminate/filesystem": "^9.0|^10.0|^11.0|^12.0", 24 | "illuminate/http": "^9.0|^10.0|^11.0|^12.0", 25 | "guzzlehttp/guzzle": "^7.4", 26 | "symfony/dom-crawler": "^6.3 || ^7.0" 27 | }, 28 | "require-dev": { 29 | "mockery/mockery": "^1.0", 30 | "orchestra/testbench": "^7.0|^8.0|^9.0", 31 | "phpunit/phpunit": "^9.0|^10.0|^11.0", 32 | "larastan/larastan": "^2.0|^3.0" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "AshAllenDesign\\FaviconFetcher\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "AshAllenDesign\\FaviconFetcher\\Tests\\": "tests/" 42 | } 43 | }, 44 | "extra": { 45 | "laravel": { 46 | "providers": [ 47 | "AshAllenDesign\\FaviconFetcher\\FaviconFetcherProvider" 48 | ] 49 | } 50 | }, 51 | "scripts": { 52 | "test": "vendor/bin/phpunit", 53 | "larastan": "vendor/bin/phpstan analyse" 54 | }, 55 | "minimum-stability": "dev", 56 | "prefer-stable": true 57 | } 58 | -------------------------------------------------------------------------------- /config/favicon-fetcher.php: -------------------------------------------------------------------------------- 1 | 'http', 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Caching 23 | |-------------------------------------------------------------------------- 24 | | 25 | | The package provides support for caching the fetched favicon's URLs. 26 | | Here, you can specify the different options for caching, such as 27 | | cache prefix that is prepended to all the cache keys. 28 | | 29 | */ 30 | 'cache' => [ 31 | 'prefix' => 'favicon-fetcher', 32 | ], 33 | 34 | /* 35 | |-------------------------------------------------------------------------- 36 | | HTTP Timeouts 37 | |-------------------------------------------------------------------------- 38 | | 39 | | Set the timeouts here in seconds for the HTTP requests that are made 40 | | to fetch the favicons. If the timeout is set to 0, then no timeout 41 | | will be applied. The connect timeout is the time taken to connect 42 | | to the server, while the timeout is the time taken to get a 43 | | response from the server after the connection is made. 44 | | 45 | */ 46 | 'timeout' => 0, 47 | 48 | 'connect_timeout' => 0, 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Verify TLS 53 | |-------------------------------------------------------------------------- 54 | | 55 | | Sets the TLS verification option when making HTTP requests, which is 56 | | enabled by default. 57 | */ 58 | 'verify_tls' => env('FAVICON_FETCHER_VERIFY_TLS', true), 59 | 60 | /* 61 | |-------------------------------------------------------------------------- 62 | | HTTP User Agent 63 | |-------------------------------------------------------------------------- 64 | | 65 | | Set the user agent used by the HTTP client when fetching the favicons. 66 | | 67 | */ 68 | 'user_agent' => env('FAVICON_FETCHER_USER_AGENT'), 69 | ]; 70 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-jc-allen/favicon-fetcher/e9e79ec1286066d216ecffdad360cd28ea2a666a/docs/logo.png -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/larastan/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src 8 | 9 | level: 6 10 | 11 | ignoreErrors: 12 | - "#^Unsafe usage of new static#" 13 | - '#^Call to an undefined method AshAllenDesign\\FaviconFetcher\\Collections\\FaviconCollection::fetch\(\)#' 14 | - '#^Call to an undefined method AshAllenDesign\\FaviconFetcher\\Collections\\FaviconCollection::fetchAll\(\)#' 15 | 16 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Collections/FaviconCollection.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class FaviconCollection extends Collection 17 | { 18 | use HasDefaultFunctionality; 19 | 20 | /** 21 | * Whether the favicons in this collection were all retrieved from the cache. 22 | */ 23 | protected bool $retrievedFromCache = false; 24 | 25 | /** 26 | * @param array> $items 27 | * @return static 28 | */ 29 | public static function makeFromCache(array $items = []): static 30 | { 31 | $collection = new static($items); 32 | 33 | $collection->retrievedFromCache = true; 34 | 35 | return $collection; 36 | } 37 | 38 | /** 39 | * Cache the collection of favicons. We only cache the collection if it contains 40 | * items and if it was not retrieved from the cache. If the collection was 41 | * retrieved from the cache, then the "force" flag has to be set to 42 | * true in order to cache it. 43 | */ 44 | public function cache(CarbonInterface $ttl, bool $force = false): self 45 | { 46 | $shouldCache = $this->isNotEmpty() 47 | && ($force || ! $this->retrievedFromCache); 48 | 49 | if ($shouldCache) { 50 | $cacheKey = $this->buildCacheKeyForCollection($this->first()->getUrl()); 51 | 52 | $cacheData = $this->map(fn (Favicon $favicon): array => $favicon->toCache())->all(); 53 | 54 | Cache::put($cacheKey, $cacheData, $ttl); 55 | } 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * Get the favicon with the largest icon size. Any icons with an unknown size (null) 62 | * will be treated as having a size of 0. 63 | */ 64 | public function largest(): ?Favicon 65 | { 66 | return $this->sortByDesc( 67 | fn (Favicon $favicon): ?int => $favicon->getIconSize() 68 | )->first(); 69 | } 70 | 71 | public function largestByFileSize(): ?Favicon 72 | { 73 | return $this->sortByDesc( 74 | fn (Favicon $favicon): int => strlen($favicon->content()) 75 | )->first(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Concerns/BuildsCacheKeys.php: -------------------------------------------------------------------------------- 1 | buildCacheKey($url).'.collection'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Concerns/HasDefaultFunctionality.php: -------------------------------------------------------------------------------- 1 | fetch($url)) { 56 | return $favicon; 57 | } 58 | 59 | return $default instanceof \Closure ? $default($url) : $default; 60 | } 61 | 62 | /** 63 | * Attempt to fetch all the favicons for the given URL. If the favicons cannot 64 | * be found, return the default as a fallback. 65 | * 66 | * @param string $url 67 | * @param mixed $default 68 | * @return mixed 69 | * 70 | * @throws FaviconNotFoundException 71 | * @throws InvalidUrlException 72 | * @throws FeatureNotSupportedException 73 | */ 74 | public function fetchAllOr(string $url, mixed $default): mixed 75 | { 76 | $favicons = $this->fetchAll($url); 77 | 78 | if ($favicons->isNotEmpty()) { 79 | return $favicons; 80 | } 81 | 82 | return $default instanceof \Closure ? $default($url) : $default; 83 | } 84 | 85 | /** 86 | * Specify whether to throw an exception if the favicon cannot be found. 87 | * 88 | * @param bool $throw 89 | * @return $this 90 | */ 91 | public function throw(bool $throw = true): self 92 | { 93 | $this->throwOnNotFound = $throw; 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * Specify which drivers should be used as fallbacks if the current 100 | * driver cannot find the favicon. 101 | * 102 | * @param string ...$fallbacks 103 | * @return $this 104 | */ 105 | public function withFallback(string ...$fallbacks): self 106 | { 107 | $this->fallbacks = array_merge($this->fallbacks, $fallbacks); 108 | 109 | return $this; 110 | } 111 | 112 | /** 113 | * Specify whether to attempt to read the favicon from the cache. 114 | * 115 | * @param bool $useCache 116 | * @return $this 117 | */ 118 | public function useCache(bool $useCache = true): self 119 | { 120 | $this->useCache = $useCache; 121 | 122 | return $this; 123 | } 124 | 125 | /** 126 | * Handle what happens if the favicon cannot be found using the current 127 | * driver. If any fallbacks are specified, attempt to find a favicon 128 | * using a different driver. If we have specified to throw an 129 | * exception, then do so. Otherwise, return null. 130 | * 131 | * @param string $url 132 | * @return FetchedFavicon|null 133 | * 134 | * @throws FaviconNotFoundException 135 | */ 136 | protected function notFound(string $url) 137 | { 138 | if ($favicon = $this->attemptFallbacks($url)) { 139 | return $favicon; 140 | } 141 | 142 | if ($this->throwOnNotFound) { 143 | throw new FaviconNotFoundException('A favicon cannot be found for '.$url); 144 | } 145 | 146 | return null; 147 | } 148 | 149 | /** 150 | * Loop through each fallback driver and attempt to retrieve a favicon. 151 | * 152 | * @param string $url 153 | * @return FetchedFavicon|null 154 | */ 155 | protected function attemptFallbacks(string $url): ?FetchedFavicon 156 | { 157 | foreach ($this->fallbacks as $driver) { 158 | if ($favicon = Favicon::driver($driver)->fetch($url)) { 159 | return $favicon; 160 | } 161 | } 162 | 163 | return null; 164 | } 165 | 166 | /** 167 | * Return the cached favicon, if one exists, or return null. 168 | * 169 | * @param string $url 170 | * @return FetchedFavicon|null 171 | * 172 | * @throws FaviconFetcherException 173 | */ 174 | protected function attemptToFetchFromCache(string $url): ?FetchedFavicon 175 | { 176 | $cachedFaviconData = Cache::get($this->buildCacheKey($url)); 177 | 178 | if (! $cachedFaviconData) { 179 | return null; 180 | } 181 | 182 | // If the cached data is still stored in the older format used in 183 | // v1 of the package, then we convert it to the new format. In 184 | // v3 of the package, we will remove this check and enforce 185 | // an array to be stored. 186 | if (is_string($cachedFaviconData)) { 187 | $cachedFaviconData = [ 188 | 'favicon_url' => $cachedFaviconData, 189 | 'icon_type' => FetchedFavicon::TYPE_ICON_UNKNOWN, 190 | 'icon_size' => null, 191 | ]; 192 | } 193 | 194 | return (new FetchedFavicon( 195 | url: $url, 196 | faviconUrl: $cachedFaviconData['favicon_url'], 197 | retrievedFromCache: true, 198 | )) 199 | ->setIconType($cachedFaviconData['icon_type']) 200 | ->setIconSize($cachedFaviconData['icon_size']); 201 | } 202 | 203 | /** 204 | * Return a collection of cached favicons if they exist, or return null. 205 | * 206 | * @param string $url 207 | * @return FaviconCollection|null 208 | * 209 | * @throws FaviconFetcherException 210 | */ 211 | protected function attemptToFetchCollectionFromCache(string $url): ?FaviconCollection 212 | { 213 | $cachedFaviconsData = Cache::get($this->buildCacheKeyForCollection($url)); 214 | 215 | if (! $cachedFaviconsData) { 216 | return null; 217 | } 218 | 219 | $favicons = new FaviconCollection(); 220 | 221 | foreach ($cachedFaviconsData as $cachedFaviconData) { 222 | $favicons->push((new FetchedFavicon( 223 | url: $url, 224 | faviconUrl: $cachedFaviconData['favicon_url'], 225 | retrievedFromCache: true, 226 | )) 227 | ->setIconType($cachedFaviconData['icon_type']) 228 | ->setIconSize($cachedFaviconData['icon_size'])); 229 | } 230 | 231 | return $favicons; 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/Concerns/MakesHttpRequests.php: -------------------------------------------------------------------------------- 1 | connectTimeout(config('favicon-fetcher.connect_timeout')); 18 | 19 | if ($userAgent = config('favicon-fetcher.user_agent')) { 20 | $client->withUserAgent($userAgent); 21 | } 22 | 23 | if (! config('favicon-fetcher.verify_tls')) { 24 | $client->withoutVerifying(); 25 | } 26 | 27 | return $client; 28 | } 29 | 30 | protected function withRequestExceptionHandling(\Closure $callback): mixed 31 | { 32 | try { 33 | return $callback(); 34 | } catch (ClientConnectionException $exception) { 35 | throw new ConnectionException( 36 | $exception->getMessage(), 37 | $exception->getCode(), 38 | $exception 39 | ); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Concerns/ValidatesUrls.php: -------------------------------------------------------------------------------- 1 | urlIsValid($url)) { 42 | throw new InvalidUrlException($url.' is not a valid URL'); 43 | } 44 | 45 | if ($this->useCache && $favicon = $this->attemptToFetchFromCache($url)) { 46 | return $favicon; 47 | } 48 | 49 | $urlWithoutProtocol = str_replace(['https://', 'http://'], '', $url); 50 | 51 | $apiUrl = self::BASE_URL.$urlWithoutProtocol; 52 | 53 | $response = $this->withRequestExceptionHandling( 54 | fn (): Response => $this->httpClient()->get($apiUrl) 55 | ); 56 | 57 | if (! $response->successful() || count($response->json('icons')) === 0) { 58 | return $this->notFound($url); 59 | } 60 | 61 | $faviconUrl = $response->json('icons')[0]['src']; 62 | 63 | return new Favicon(url: $url, faviconUrl: $faviconUrl, fromDriver: $this); 64 | } 65 | 66 | public function fetchAll(string $url): FaviconCollection 67 | { 68 | throw new FeatureNotSupportedException('The FaviconGrabber driver does not support fetching all favicons.'); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Drivers/FaviconKitDriver.php: -------------------------------------------------------------------------------- 1 | urlIsValid($url)) { 40 | throw new InvalidUrlException($url.' is not a valid URL'); 41 | } 42 | 43 | if ($this->useCache && $favicon = $this->attemptToFetchFromCache($url)) { 44 | return $favicon; 45 | } 46 | 47 | $urlWithoutProtocol = str_replace(['https://', 'http://'], '', $url); 48 | 49 | $faviconUrl = self::BASE_URL.$urlWithoutProtocol; 50 | 51 | $response = $this->withRequestExceptionHandling( 52 | fn (): Response => $this->httpClient()->get($faviconUrl) 53 | ); 54 | 55 | return $response->successful() 56 | ? new Favicon(url: $url, faviconUrl: $faviconUrl, fromDriver: $this) 57 | : $this->notFound($url); 58 | } 59 | 60 | public function fetchAll(string $url): FaviconCollection 61 | { 62 | throw new FeatureNotSupportedException('The FaviconKit API does not support fetching all favicons.'); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Drivers/GoogleSharedStuffDriver.php: -------------------------------------------------------------------------------- 1 | urlIsValid($url)) { 42 | throw new InvalidUrlException($url.' is not a valid URL'); 43 | } 44 | 45 | if ($this->useCache && $favicon = $this->attemptToFetchFromCache($url)) { 46 | return $favicon; 47 | } 48 | 49 | $faviconUrl = self::BASE_URL.$url; 50 | 51 | $response = $this->withRequestExceptionHandling( 52 | fn (): Response => $this->httpClient()->get($faviconUrl) 53 | ); 54 | 55 | return $response->successful() 56 | ? new Favicon(url: $url, faviconUrl: $faviconUrl, fromDriver: $this) 57 | : $this->notFound($url); 58 | } 59 | 60 | public function fetchAll(string $url): FaviconCollection 61 | { 62 | throw new FeatureNotSupportedException('The Google Shared Stuff API does not support fetching all favicons.'); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Drivers/HttpDriver.php: -------------------------------------------------------------------------------- 1 | urlIsValid($url)) { 43 | throw new InvalidUrlException($url.' is not a valid URL'); 44 | } 45 | 46 | if ($this->useCache && $favicon = $this->attemptToFetchFromCache($url)) { 47 | return $favicon; 48 | } 49 | 50 | $favicon = $this->attemptToResolveFromHeadTags($url) 51 | ?? new Favicon(url: $url, faviconUrl: $this->guessDefaultUrl($url), fromDriver: $this); 52 | 53 | $faviconCanBeReached = $this->faviconUrlCanBeReached($favicon->getFaviconUrl()); 54 | 55 | return $faviconCanBeReached 56 | ? $favicon 57 | : $this->notFound($url); 58 | } 59 | 60 | public function fetchAll(string $url): FaviconCollection 61 | { 62 | if (! $this->urlIsValid($url)) { 63 | throw new InvalidUrlException($url.' is not a valid URL'); 64 | } 65 | 66 | if ($this->useCache && $favicons = $this->attemptToFetchCollectionFromCache($url)) { 67 | return $favicons; 68 | } 69 | 70 | $favicons = $this->attemptToResolveAllFromHeadTags($url); 71 | 72 | // If the URL couldn't be reached, throw and exception and return 73 | // an empty FaviconCollection. 74 | if ($favicons === null) { 75 | if ($this->throwOnNotFound) { 76 | throw new FaviconNotFoundException('A favicon cannot be found for '.$url); 77 | } 78 | 79 | $favicons = new FaviconCollection(); 80 | } 81 | 82 | if ($favicons->isEmpty()) { 83 | $favicons->push(new Favicon(url: $url, faviconUrl: $this->guessDefaultUrl($url), fromDriver: $this)); 84 | } 85 | 86 | // Return a FaviconCollection of favicons that can be reached. 87 | return $favicons->filter( 88 | fn (Favicon $favicon): bool => $this->faviconUrlCanBeReached($favicon->getFaviconUrl()) 89 | ); 90 | } 91 | 92 | /** 93 | * Attempt to resolve a favicon from the given URL. If the response 94 | * is successful, we can assume that a valid favicon was returned. 95 | * Otherwise, we can assume that a favicon wasn't found. 96 | * 97 | * @param string $faviconUrl 98 | * @return bool 99 | * 100 | * @throws ConnectionException 101 | */ 102 | private function faviconUrlCanBeReached(string $faviconUrl): bool 103 | { 104 | return $this->withRequestExceptionHandling( 105 | fn (): bool => $this->httpClient() 106 | ->get($faviconUrl) 107 | ->successful() 108 | ); 109 | } 110 | 111 | /** 112 | * Parse the HTML returned from the URL and attempt to find a favicon 113 | * specified using the "icon" or "shortcut icon" link tag. If one 114 | * is found, return the absolute URL of the link's "href". 115 | * Otherwise, return null. 116 | * 117 | * @param string $url 118 | * @return Favicon|null 119 | * 120 | * @throws InvalidIconSizeException 121 | * @throws InvalidIconTypeException 122 | * @throws ConnectionException 123 | */ 124 | private function attemptToResolveFromHeadTags(string $url): ?Favicon 125 | { 126 | $response = $this->withRequestExceptionHandling( 127 | fn (): Response => $this->httpClient()->get($url) 128 | ); 129 | 130 | if (! $response->successful()) { 131 | return null; 132 | } 133 | 134 | $linkTag = (new Crawler($response->body())) 135 | ->filter(' 136 | head link[rel="icon"][href], 137 | head link[rel="shortcut icon"][href] 138 | ') 139 | ->first(); 140 | 141 | if (! $linkTag->count()) { 142 | return null; 143 | } 144 | 145 | $favicon = new Favicon( 146 | url: $url, 147 | faviconUrl: $this->convertToAbsoluteUrl($url, $linkTag->attr('href')), 148 | fromDriver: $this, 149 | ); 150 | 151 | if ($iconSize = $linkTag->attr('sizes')) { 152 | $favicon->setIconSize((int) $iconSize); 153 | } 154 | 155 | if ($iconType = $this->guessTypeFromElement($linkTag)) { 156 | $favicon->setIconType($iconType); 157 | } 158 | 159 | return $favicon; 160 | } 161 | 162 | /** 163 | * @throws ConnectionException 164 | */ 165 | private function attemptToResolveAllFromHeadTags(string $url): ?FaviconCollection 166 | { 167 | $response = $this->withRequestExceptionHandling( 168 | fn (): Response => $this->httpClient()->get($url) 169 | ); 170 | 171 | if (! $response->successful()) { 172 | return null; 173 | } 174 | 175 | $linkTags = (new Crawler($response->body())) 176 | ->filter(' 177 | head link[rel="icon"][href], 178 | head link[rel="shortcut icon"][href], 179 | head link[rel="apple-touch-icon"][href] 180 | '); 181 | 182 | if (! $linkTags->count()) { 183 | return null; 184 | } 185 | 186 | $favicons = $linkTags->each(function (Crawler $linkTag) use ($url): Favicon { 187 | $favicon = new Favicon( 188 | $url, 189 | $this->convertToAbsoluteUrl($url, $linkTag->attr('href')), 190 | $this, 191 | ); 192 | 193 | if ($iconSize = $linkTag->attr('sizes')) { 194 | $favicon->setIconSize((int) $iconSize); 195 | } 196 | 197 | if ($iconType = $this->guessTypeFromElement($linkTag)) { 198 | $favicon->setIconType($iconType); 199 | } 200 | 201 | return $favicon; 202 | }); 203 | 204 | return new FaviconCollection($favicons); 205 | } 206 | 207 | private function guessTypeFromElement(Crawler $linkElement): string 208 | { 209 | return match ($linkElement->attr('rel')) { 210 | 'icon' => Favicon::TYPE_ICON, 211 | 'shortcut icon' => Favicon::TYPE_SHORTCUT_ICON, 212 | 'apple-touch-icon' => Favicon::TYPE_APPLE_TOUCH_ICON, 213 | default => Favicon::TYPE_ICON_UNKNOWN, 214 | }; 215 | } 216 | 217 | /** 218 | * Convert the favicon URL to be absolute rather than relative. 219 | * 220 | * @param string $baseUrl 221 | * @param string $faviconUrl 222 | * @return string 223 | */ 224 | private function convertToAbsoluteUrl(string $baseUrl, string $faviconUrl): string 225 | { 226 | // If the favicon URL is relative, we need to convert it to be absolute. 227 | // We also strip the path (if there is one) from the base URL. 228 | if (! filter_var($faviconUrl, FILTER_VALIDATE_URL)) { 229 | $faviconUrl = $this->stripPathFromUrl($baseUrl).'/'.ltrim($faviconUrl, '/'); 230 | } 231 | 232 | return $faviconUrl; 233 | } 234 | 235 | /** 236 | * Build and return the default path where we can guess the favicon 237 | * file might be stored. 238 | * 239 | * @param string $url 240 | * @return string 241 | */ 242 | private function guessDefaultUrl(string $url): string 243 | { 244 | return rtrim($this->stripPathFromUrl($url)).'/favicon.ico'; 245 | } 246 | 247 | /** 248 | * Strip the path and any query parameters from the given URL so that 249 | * we only return the scheme, host and port (if there is one). 250 | * 251 | * @param string $url 252 | * @return string 253 | */ 254 | private function stripPathFromUrl(string $url): string 255 | { 256 | $parsedUrl = parse_url($url); 257 | 258 | $url = $parsedUrl['scheme'].'://'.$parsedUrl['host']; 259 | 260 | if (array_key_exists('port', $parsedUrl)) { 261 | $url .= ':'.$parsedUrl['port']; 262 | } 263 | 264 | return $url; 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/Drivers/UnavatarDriver.php: -------------------------------------------------------------------------------- 1 | urlIsValid($url)) { 40 | throw new InvalidUrlException($url.' is not a valid URL'); 41 | } 42 | 43 | if ($this->useCache && $favicon = $this->attemptToFetchFromCache($url)) { 44 | return $favicon; 45 | } 46 | 47 | $urlWithoutProtocol = str_replace(['https://', 'http://'], '', $url); 48 | 49 | $faviconUrl = self::BASE_URL.$urlWithoutProtocol.'?fallback=false'; 50 | 51 | $response = $this->withRequestExceptionHandling( 52 | fn (): Response => $this->httpClient()->get($faviconUrl) 53 | ); 54 | 55 | return $response->successful() 56 | ? new Favicon(url: $url, faviconUrl: $faviconUrl, fromDriver: $this) 57 | : $this->notFound($url); 58 | } 59 | 60 | public function fetchAll(string $url): FaviconCollection 61 | { 62 | throw new FeatureNotSupportedException('The Unavatar API does not support fetching all favicons.'); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Exceptions/ConnectionException.php: -------------------------------------------------------------------------------- 1 | url = $url; 73 | $this->faviconUrl = $faviconUrl; 74 | $this->driver = $fromDriver; 75 | $this->retrievedFromCache = $retrievedFromCache; 76 | } 77 | 78 | public function setIconSize(?int $size): static 79 | { 80 | if ($size !== null && $size < 0) { 81 | throw new InvalidIconSizeException('The size ['.$size.'] is not a valid favicon size.'); 82 | } 83 | 84 | $this->size = $size; 85 | 86 | return $this; 87 | } 88 | 89 | public function setIconType(string $type): static 90 | { 91 | if (! $this->acceptableIconType($type)) { 92 | throw new InvalidIconTypeException('The type ['.$type.'] is not a valid favicon type.'); 93 | } 94 | 95 | $this->iconType = $type; 96 | 97 | return $this; 98 | } 99 | 100 | public function getUrl(): string 101 | { 102 | return $this->url; 103 | } 104 | 105 | public function getFaviconUrl(): string 106 | { 107 | return $this->faviconUrl; 108 | } 109 | 110 | public function retrievedFromCache(): bool 111 | { 112 | return $this->retrievedFromCache; 113 | } 114 | 115 | /** 116 | * Get the contents of the favicon file. 117 | * 118 | * @return string 119 | * 120 | * @throws ConnectionException 121 | */ 122 | public function content(): string 123 | { 124 | return $this->withRequestExceptionHandling( 125 | fn (): Response => $this->httpClient()->get($this->faviconUrl) 126 | )->body(); 127 | } 128 | 129 | /** 130 | * Cache the favicon URL. If the favicon is already cached, "force" 131 | * must be passed as "true" to re-cache the URL. 132 | * 133 | * @param CarbonInterface $ttl 134 | * @param bool $force 135 | * @return $this 136 | */ 137 | public function cache(CarbonInterface $ttl, bool $force = false): self 138 | { 139 | if ($force || ! $this->retrievedFromCache) { 140 | Cache::put( 141 | $this->buildCacheKey($this->url), 142 | $this->toCache(), 143 | $ttl 144 | ); 145 | } 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * Store the favicon in storage using an automatically generate filename. 152 | * 153 | * @param string $directory 154 | * @param string|null $disk 155 | * @return string 156 | */ 157 | public function store(string $directory, ?string $disk = null): string 158 | { 159 | return $this->storeAs($directory, Str::uuid()->toString(), $disk); 160 | } 161 | 162 | /** 163 | * Store the favicon in storage. 164 | * 165 | * @param string $directory 166 | * @param string $filename 167 | * @param string|null $disk 168 | * @return string 169 | */ 170 | public function storeAs(string $directory, string $filename, ?string $disk = null): string 171 | { 172 | $path = $this->buildStoragePath($directory, $filename); 173 | 174 | Storage::disk($disk)->put($path, $this->content()); 175 | 176 | return $path; 177 | } 178 | 179 | public function getIconType(): string 180 | { 181 | return $this->iconType; 182 | } 183 | 184 | public function getIconSize(): ?int 185 | { 186 | return $this->size; 187 | } 188 | 189 | protected function buildStoragePath(string $directory, string $filename): string 190 | { 191 | return Str::of($directory) 192 | ->append('/') 193 | ->append($filename) 194 | ->append('.') 195 | ->append($this->guessFileExtension()) 196 | ->toString(); 197 | } 198 | 199 | protected function guessFileExtension(): string 200 | { 201 | $default = File::extension($this->faviconUrl); 202 | 203 | if (Str::of($this->faviconUrl)->endsWith(['png', 'ico', 'svg'])) { 204 | return $default; 205 | } 206 | 207 | return $this->guessFileExtensionFromMimeType() ?? $default; 208 | } 209 | 210 | /** 211 | * @throws ConnectionException 212 | */ 213 | protected function guessFileExtensionFromMimeType(): ?string 214 | { 215 | $faviconMimetype = $this->withRequestExceptionHandling( 216 | fn (): Response => $this->httpClient()->get($this->faviconUrl) 217 | )->header('content-type'); 218 | 219 | $mimeToExtensionMap = [ 220 | 'image/x-icon' => 'ico', 221 | 'image/x-ico' => 'ico', 222 | 'image/vnd.microsoft.icon' => 'ico', 223 | 'image/jpeg' => 'jpeg', 224 | 'image/pjpeg' => 'jpeg', 225 | 'image/png' => 'png', 226 | 'image/x-png' => 'png', 227 | 'image/svg+xml' => 'svg', 228 | ]; 229 | 230 | return $mimeToExtensionMap[$faviconMimetype] ?? null; 231 | } 232 | 233 | private function acceptableIconType(string $type): bool 234 | { 235 | return in_array( 236 | needle: $type, 237 | haystack: [ 238 | self::TYPE_ICON, 239 | self::TYPE_SHORTCUT_ICON, 240 | self::TYPE_APPLE_TOUCH_ICON, 241 | self::TYPE_ICON_UNKNOWN, 242 | ], 243 | strict: true); 244 | } 245 | 246 | /** 247 | * Transform the favicon object into an array that can be cached. 248 | * 249 | * @return array 250 | */ 251 | public function toCache(): array 252 | { 253 | return [ 254 | 'favicon_url' => $this->getFaviconUrl(), 255 | 'icon_size' => $this->getIconSize(), 256 | 'icon_type' => $this->getIconType(), 257 | ]; 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/FaviconFetcherProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/../config/favicon-fetcher.php', 'favicon-fetcher'); 19 | 20 | $this->app->bind('favicon-fetcher', fn () => new FetcherManager()); 21 | } 22 | 23 | /** 24 | * Bootstrap any application services. 25 | * 26 | * @return void 27 | */ 28 | public function boot(): void 29 | { 30 | $this->publishes([ 31 | __DIR__.'/../config/favicon-fetcher.php' => config_path('favicon-fetcher.php'), 32 | ], 'favicon-fetcher-config'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/FetcherManager.php: -------------------------------------------------------------------------------- 1 | new HttpDriver(), 28 | 'google-shared-stuff' => new GoogleSharedStuffDriver(), 29 | 'favicon-kit' => new FaviconKitDriver(), 30 | 'unavatar' => new UnavatarDriver(), 31 | 'favicon-grabber' => new FaviconGrabberDriver(), 32 | default => static::attemptToCreateCustomDriver($driver), 33 | }; 34 | } 35 | 36 | public static function extend(string $name, Fetcher $fetcher): void 37 | { 38 | self::$customDrivers[$name] = $fetcher; 39 | } 40 | 41 | protected static function attemptToCreateCustomDriver(string $driver): Fetcher 42 | { 43 | return static::$customDrivers[$driver] 44 | ?? throw new FaviconFetcherException($driver.' is not a valid driver.'); 45 | } 46 | 47 | public function __call(string $method, mixed $parameters): mixed 48 | { 49 | return static::driver()->$method(...$parameters); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Feature/Collections/FaviconCollectionTest.php: -------------------------------------------------------------------------------- 1 | setIconSize(180)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 22 | (new Favicon('https://example.com', 'https://example.com/images/favicon.ico'))->setIconType(Favicon::TYPE_SHORTCUT_ICON), 23 | ]); 24 | 25 | $collection->cache(now()->addDay()); 26 | 27 | $cachedItems = Cache::get('favicon-fetcher.example.com.collection'); 28 | 29 | self::assertSame( 30 | expected: [ 31 | [ 32 | 'favicon_url' => 'https://example.com/images/apple-icon-180x180.png', 33 | 'icon_size' => 180, 34 | 'icon_type' => 'apple_touch_icon', 35 | ], 36 | [ 37 | 'favicon_url' => 'https://example.com/images/favicon.ico', 38 | 'icon_size' => null, 39 | 'icon_type' => 'shortcut_icon', 40 | ], 41 | ], 42 | actual: $cachedItems 43 | ); 44 | } 45 | 46 | /** @test */ 47 | public function favicon_collection_can_be_cached_if_the_collection_was_retrieved_from_the_cache_and_the_force_flag_is_true(): void 48 | { 49 | Cache::put( 50 | key: 'favicon-fetcher.example.com.collection', 51 | value: 'Dummy value here that should be overridden', 52 | ttl: now()->addDay(), 53 | ); 54 | 55 | FaviconCollection::makeFromCache([ 56 | (new Favicon('https://example.com', 'https://example.com/images/apple-icon-180x180.png'))->setIconSize(180)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 57 | (new Favicon('https://example.com', 'https://example.com/images/favicon.ico'))->setIconType(Favicon::TYPE_SHORTCUT_ICON), 58 | ])->cache(now()->addDay(), true); 59 | 60 | // Assert that the items in the database were overridden. 61 | self::assertSame( 62 | expected: [ 63 | [ 64 | 'favicon_url' => 'https://example.com/images/apple-icon-180x180.png', 65 | 'icon_size' => 180, 66 | 'icon_type' => 'apple_touch_icon', 67 | ], 68 | [ 69 | 'favicon_url' => 'https://example.com/images/favicon.ico', 70 | 'icon_size' => null, 71 | 'icon_type' => 'shortcut_icon', 72 | ], 73 | ], 74 | actual: Cache::get('favicon-fetcher.example.com.collection') 75 | ); 76 | } 77 | 78 | /** @test */ 79 | public function favicon_collection_is_not_cached_if_the_collection_was_retrieved_from_the_cache_and_the_force_flag_is_false(): void 80 | { 81 | Cache::put( 82 | key: 'favicon-fetcher.example.com.collection', 83 | value: 'Dummy value here that should not be overridden', 84 | ttl: now()->addDay(), 85 | ); 86 | 87 | FaviconCollection::makeFromCache([ 88 | (new Favicon('https://example.com', 'https://example.com/images/apple-icon-180x180.png'))->setIconSize(180)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 89 | (new Favicon('https://example.com', 'https://example.com/images/favicon.ico'))->setIconType(Favicon::TYPE_SHORTCUT_ICON), 90 | ])->cache(now()->addDay()); 91 | 92 | // Assert that the items in the database were not overridden. 93 | self::assertSame( 94 | expected: 'Dummy value here that should not be overridden', 95 | actual: Cache::get('favicon-fetcher.example.com.collection') 96 | ); 97 | } 98 | 99 | /** @test */ 100 | public function favicon_collection_is_not_cached_if_the_collection_is_empty(): void 101 | { 102 | Cache::shouldReceive('put')->never(); 103 | 104 | $collection = new FaviconCollection(); 105 | 106 | $collection->cache(now()->addDay()); 107 | } 108 | 109 | /** @test */ 110 | public function largest_favicon_can_be_retrieved(): void 111 | { 112 | $largest = FaviconCollection::make([ 113 | (new Favicon('https://example.com', 'https://example.com/favicon/favicon-32x32.png'))->setIconSize(null)->setIconType(Favicon::TYPE_ICON), 114 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-57x57.png'))->setIconSize(57)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 115 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-60x60.png'))->setIconSize(60)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 116 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-72x72.png'))->setIconSize(72)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 117 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-72x72.png'))->setIconSize(76)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 118 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-76x76.png'))->setIconSize(114)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 119 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-120x120.png'))->setIconSize(120)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 120 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-144x144.png'))->setIconSize(144)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 121 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-152x152.png'))->setIconSize(152)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 122 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-180x180.png'))->setIconSize(180)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 123 | (new Favicon('https://example.com', 'https://example.com/favicon/android-icon-192x192.png'))->setIconSize(192)->setIconType(Favicon::TYPE_ICON), 124 | ])->largest(); 125 | 126 | self::assertSame('https://example.com/favicon/android-icon-192x192.png', $largest->getFaviconUrl()); 127 | } 128 | 129 | /** @test */ 130 | public function largest_favicon_can_be_retrieved_if_there_are_only_null_sizes(): void 131 | { 132 | $largest = FaviconCollection::make([ 133 | (new Favicon('https://example.com', 'https://example.com/favicon/favicon-32x32.png'))->setIconSize(null)->setIconType(Favicon::TYPE_ICON), 134 | (new Favicon('https://example.com', 'https://example.com/favicon/favicon-64x64.png'))->setIconSize(null)->setIconType(Favicon::TYPE_ICON), 135 | ])->largest(); 136 | 137 | self::assertSame('https://example.com/favicon/favicon-32x32.png', $largest->getFaviconUrl()); 138 | } 139 | 140 | /** @test */ 141 | public function largest_favicon_can_be_retrieved_based_on_file_size() 142 | { 143 | // mock the favicons to specify file content lengths 144 | $favicon1 = $this->createMock(Favicon::class); 145 | $favicon1->method('getFaviconUrl')->willReturn('https://example.com/favicon/favicon-32x32.png'); 146 | $favicon1->method('content')->willReturn('some-short-string'); 147 | 148 | $favicon2 = $this->createMock(Favicon::class); 149 | $favicon2->method('getFaviconUrl')->willReturn('https://example.com/favicon/favicon-64x64.png'); 150 | $favicon2->method('content')->willReturn('some-much-longer-string'); 151 | 152 | $largest = FaviconCollection::make([ 153 | $favicon1, 154 | $favicon2, 155 | ])->largestByFileSize(); 156 | 157 | self::assertSame('https://example.com/favicon/favicon-64x64.png', $largest->getFaviconUrl()); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /tests/Feature/Concerns/MakesHttpRequests/HttpClientTest.php: -------------------------------------------------------------------------------- 1 | 10, 19 | 'favicon-fetcher.connect_timeout' => 5, 20 | 'favicon-fetcher.verify_tls' => false, 21 | ]); 22 | 23 | $client = $this->httpClient(); 24 | 25 | self::assertEquals(10, $client->getOptions()['timeout']); 26 | self::assertEquals(5, $client->getOptions()['connect_timeout']); 27 | self::assertFalse($client->getOptions()['verify']); 28 | } 29 | 30 | /** @test */ 31 | public function http_client_is_returned_with_correct_verify_tls_option(): void 32 | { 33 | config([ 34 | 'favicon-fetcher.verify_tls' => true, 35 | ]); 36 | 37 | $client = $this->httpClient(); 38 | 39 | // The "verify" option shouldn't be present because we've not set it, 40 | // so if it's not in the array, we can make the assumption that it's 41 | // set to true under the hood by Laravel. 42 | self::assertArrayNotHasKey('verify', $client->getOptions()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Feature/Concerns/MakesHttpRequests/WithRequestExceptionHandlingTest.php: -------------------------------------------------------------------------------- 1 | expectException(ConnectionException::class); 20 | 21 | $this->withRequestExceptionHandling(function () { 22 | throw new ClientConnectionException('Test exception'); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Feature/Drivers/FaviconGrabberDriverTest.php: -------------------------------------------------------------------------------- 1 | Http::response($this->successfulResponseBody()), 33 | '*' => Http::response('should not hit here'), 34 | ]); 35 | 36 | $favicon = (new FaviconGrabberDriver())->fetch($protocol.'://aws.amazon.com'); 37 | 38 | self::assertSame('https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico', $favicon->getFaviconUrl()); 39 | } 40 | 41 | /** @test */ 42 | public function favicon_can_be_fetched_from_the_cache_if_it_already_exists(): void 43 | { 44 | Cache::put( 45 | 'favicon-fetcher.aws.amazon.com', 46 | [ 47 | 'favicon_url' => 'url-goes-here', 48 | 'icon_size' => null, 49 | 'icon_type' => Favicon::TYPE_ICON_UNKNOWN, 50 | ], 51 | now()->addHour() 52 | ); 53 | 54 | Http::fake([ 55 | '*' => Http::response('should not hit here'), 56 | ]); 57 | 58 | $favicon = (new FaviconGrabberDriver())->fetch('https://aws.amazon.com'); 59 | 60 | self::assertSame('url-goes-here', $favicon->getFaviconUrl()); 61 | } 62 | 63 | /** @test */ 64 | public function favicon_is_not_fetched_from_the_cache_if_it_exists_but_the_use_cache_flag_is_false(): void 65 | { 66 | Cache::put( 67 | 'favicon-fetcher.https://aws.amazon.com', 68 | 'url-goes-here', 69 | now()->addHour() 70 | ); 71 | 72 | Http::fake([ 73 | 'https://favicongrabber.com/api/grab/aws.amazon.com' => Http::response($this->successfulResponseBody()), 74 | '*' => Http::response('should not hit here'), 75 | ]); 76 | 77 | $favicon = (new FaviconGrabberDriver())->useCache(false)->fetch('https://aws.amazon.com'); 78 | 79 | self::assertSame('https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico', $favicon->getFaviconUrl()); 80 | } 81 | 82 | /** @test */ 83 | public function null_is_returned_if_the_driver_cannot_find_the_favicon(): void 84 | { 85 | Http::fake([ 86 | 'https://favicongrabber.com/api/grab/empty.com' => Http::response($this->successfulEmptyResponseBody()), 87 | '*' => Http::response('should not hit here'), 88 | ]); 89 | 90 | $favicon = (new FaviconGrabberDriver())->useCache(true)->fetch('https://empty.com'); 91 | 92 | self::assertNull($favicon); 93 | } 94 | 95 | /** @test */ 96 | public function null_is_returned_if_the_domain_is_invalid(): void 97 | { 98 | Http::fake([ 99 | 'https://favicongrabber.com/api/grab/invalid.com' => Http::response($this->domainNotFoundResponseBody(), 400), 100 | '*' => Http::response('should not hit here'), 101 | ]); 102 | 103 | $favicon = (new FaviconGrabberDriver())->useCache(true)->fetch('https://invalid.com'); 104 | 105 | self::assertNull($favicon); 106 | } 107 | 108 | /** @test */ 109 | public function fallback_is_attempted_if_the_driver_cannot_find_the_favicon(): void 110 | { 111 | Http::fake([ 112 | 'https://favicongrabber.com/api/grab/invalid.com' => Http::response($this->domainNotFoundResponseBody(), 400), 113 | '*' => Http::response('should not hit here'), 114 | ]); 115 | 116 | FetcherManager::extend('custom-driver', new CustomDriver()); 117 | 118 | $favicon = (new FaviconGrabberDriver()) 119 | ->withFallback('custom-driver') 120 | ->useCache(true) 121 | ->fetch('https://invalid.com'); 122 | 123 | self::assertSame('favicon-from-default', $favicon->getFaviconUrl()); 124 | } 125 | 126 | /** @test */ 127 | public function exception_is_thrown_if_the_driver_cannot_find_the_favicon_and_the_throw_on_not_found_flag_is_true(): void 128 | { 129 | Http::fake([ 130 | 'https://favicongrabber.com/api/grab/invalid.com' => Http::response($this->domainNotFoundResponseBody(), 400), 131 | '*' => Http::response('should not hit here'), 132 | ]); 133 | 134 | $exception = null; 135 | 136 | try { 137 | (new FaviconGrabberDriver()) 138 | ->throw() 139 | ->useCache(true) 140 | ->fetch('https://invalid.com'); 141 | } catch (\Exception $e) { 142 | $exception = $e; 143 | } 144 | 145 | self::assertInstanceOf(FaviconNotFoundException::class, $exception); 146 | self::assertSame('A favicon cannot be found for https://invalid.com', $exception->getMessage()); 147 | } 148 | 149 | /** @test */ 150 | public function default_value_can_be_returned_using_fetchOr_method(): void 151 | { 152 | Http::fake([ 153 | 'https://favicongrabber.com/api/grab/invalid.com' => Http::response($this->domainNotFoundResponseBody(), 400), 154 | '*' => Http::response('should not hit here'), 155 | ]); 156 | 157 | $favicon = (new FaviconGrabberDriver()) 158 | ->useCache(true) 159 | ->fetchOr('https://invalid.com', 'fallback-to-this'); 160 | 161 | self::assertSame('fallback-to-this', $favicon); 162 | } 163 | 164 | /** @test */ 165 | public function default_value_can_be_returned_using_fetchOr_method_with_a_closure(): void 166 | { 167 | Http::fake([ 168 | 'https://favicongrabber.com/api/grab/invalid.com' => Http::response($this->domainNotFoundResponseBody(), 400), 169 | '*' => Http::response('should not hit here'), 170 | ]); 171 | 172 | $favicon = (new FaviconGrabberDriver()) 173 | ->fetchOr('https://invalid.com', function () { 174 | return 'fallback-to-this'; 175 | }); 176 | 177 | self::assertSame('fallback-to-this', $favicon); 178 | } 179 | 180 | /** @test */ 181 | public function exception_can_be_thrown_after_attempting_a_fallback(): void 182 | { 183 | Http::fake([ 184 | 'https://favicongrabber.com/api/grab/invalid.com' => Http::response($this->domainNotFoundResponseBody(), 400), 185 | '*' => Http::response('should not hit here'), 186 | ]); 187 | 188 | FetcherManager::extend('custom-driver', new NullDriver()); 189 | 190 | $exception = null; 191 | 192 | try { 193 | (new FaviconGrabberDriver()) 194 | ->throw() 195 | ->withFallback('custom-driver') 196 | ->fetch('https://invalid.com'); 197 | } catch (\Exception $e) { 198 | $exception = $e; 199 | } 200 | 201 | self::assertInstanceOf(FaviconNotFoundException::class, $exception); 202 | self::assertSame('A favicon cannot be found for https://invalid.com', $exception->getMessage()); 203 | 204 | self::assertTrue(NullDriver::$flag); 205 | } 206 | 207 | /** @test */ 208 | public function exception_is_thrown_if_the_url_is_invalid(): void 209 | { 210 | Http::fake([ 211 | '*' => Http::response('should not hit here'), 212 | ]); 213 | 214 | $exception = null; 215 | 216 | try { 217 | (new FaviconGrabberDriver())->fetch('example.com'); 218 | } catch (\Exception $e) { 219 | $exception = $e; 220 | } 221 | 222 | self::assertInstanceOf(InvalidUrlException::class, $exception); 223 | self::assertSame('example.com is not a valid URL', $exception->getMessage()); 224 | } 225 | 226 | private function successfulResponseBody(): array 227 | { 228 | return [ 229 | 'domain' => 'aws.amazon.com', 230 | 'icons' => [ 231 | [ 232 | 'src' => 'https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico', 233 | 'type' => 'image/ico', 234 | ], 235 | [ 236 | 'src' => 'https://a0.awsstatic.com/libra-css/images/site/fav/favicon.ico', 237 | 'type' => 'image/ico', 238 | ], 239 | [ 240 | 'sizes' => '57x57', 241 | 'src' => 'https://a0.awsstatic.com/libra-css/images/site/touch-icon-iphone-114-smile.png', 242 | ], 243 | [ 244 | 'sizes' => '72x72', 245 | 'src' => 'https://a0.awsstatic.com/libra-css/images/site/touch-icon-ipad-144-smile.png', 246 | ], 247 | [ 248 | 'sizes' => '114x114', 249 | 'src' => 'https://a0.awsstatic.com/libra-css/images/site/touch-icon-iphone-114-smile.png', 250 | ], 251 | [ 252 | 'sizes' => '144x144', 253 | 'src' => 'https://a0.awsstatic.com/libra-css/images/site/touch-icon-ipad-144-smile.png', 254 | ], 255 | [ 256 | 'src' => 'https://aws.amazon.com/favicon.ico', 257 | 'type' => 'image/x-icon', 258 | ], 259 | ], 260 | ]; 261 | } 262 | 263 | private function successfulEmptyResponseBody(): array 264 | { 265 | return [ 266 | 'domain' => 'empty.com', 267 | 'icons' => [], 268 | ]; 269 | } 270 | 271 | private function domainNotFoundResponseBody(): array 272 | { 273 | return [ 274 | 'error' => 'Unresolved domain name.', 275 | ]; 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /tests/Feature/Drivers/FaviconKitDriverTest.php: -------------------------------------------------------------------------------- 1 | Http::response('favicon contents here'), 33 | '*' => Http::response('should not hit here'), 34 | ]); 35 | 36 | $favicon = (new FaviconKitDriver())->fetch($protocol.'://example.com'); 37 | 38 | self::assertSame('https://api.faviconkit.com/example.com', $favicon->getFaviconUrl()); 39 | } 40 | 41 | /** @test */ 42 | public function favicon_can_be_fetched_from_the_cache_if_it_already_exists(): void 43 | { 44 | Cache::put( 45 | 'favicon-fetcher.example.com', 46 | [ 47 | 'favicon_url' => 'url-goes-here', 48 | 'icon_size' => null, 49 | 'icon_type' => Favicon::TYPE_ICON_UNKNOWN, 50 | ], 51 | now()->addHour() 52 | ); 53 | 54 | Http::fake([ 55 | '*' => Http::response('should not hit here'), 56 | ]); 57 | 58 | $favicon = (new FaviconKitDriver())->fetch('https://example.com'); 59 | 60 | self::assertSame('url-goes-here', $favicon->getFaviconUrl()); 61 | } 62 | 63 | /** @test */ 64 | public function favicon_is_not_fetched_from_the_cache_if_it_exists_but_the_use_cache_flag_is_false(): void 65 | { 66 | Cache::put( 67 | 'favicon-fetcher.https://example.com', 68 | 'url-goes-here', 69 | now()->addHour() 70 | ); 71 | 72 | Http::fake([ 73 | 'https://api.faviconkit.com/example.com' => Http::response('favicon contents here'), 74 | '*' => Http::response('should not hit here'), 75 | ]); 76 | 77 | $favicon = (new FaviconKitDriver())->useCache(false)->fetch('https://example.com'); 78 | 79 | self::assertSame('https://api.faviconkit.com/example.com', $favicon->getFaviconUrl()); 80 | } 81 | 82 | /** @test */ 83 | public function null_is_returned_if_the_driver_cannot_find_the_favicon(): void 84 | { 85 | Http::fake([ 86 | 'https://api.faviconkit.com/example.com' => Http::response('not found', 404), 87 | '*' => Http::response('should not hit here'), 88 | ]); 89 | 90 | $favicon = (new FaviconKitDriver())->useCache(true)->fetch('https://example.com'); 91 | 92 | self::assertNull($favicon); 93 | } 94 | 95 | /** @test */ 96 | public function fallback_is_attempted_if_the_driver_cannot_find_the_favicon(): void 97 | { 98 | Http::fake([ 99 | 'https://api.faviconkit.com/example.com' => Http::response('not found', 404), 100 | '*' => Http::response('should not hit here'), 101 | ]); 102 | 103 | FetcherManager::extend('custom-driver', new CustomDriver()); 104 | 105 | $favicon = (new FaviconKitDriver()) 106 | ->withFallback('custom-driver') 107 | ->useCache(true) 108 | ->fetch('https://example.com'); 109 | 110 | self::assertSame('favicon-from-default', $favicon->getFaviconUrl()); 111 | } 112 | 113 | /** @test */ 114 | public function exception_is_thrown_if_the_driver_cannot_find_the_favicon_and_the_throw_on_not_found_flag_is_true(): void 115 | { 116 | Http::fake([ 117 | 'https://api.faviconkit.com/example.com' => Http::response('not found', 404), 118 | '*' => Http::response('should not hit here'), 119 | ]); 120 | 121 | $exception = null; 122 | 123 | try { 124 | (new FaviconKitDriver()) 125 | ->throw() 126 | ->useCache(true) 127 | ->fetch('https://example.com'); 128 | } catch (\Exception $e) { 129 | $exception = $e; 130 | } 131 | 132 | self::assertInstanceOf(FaviconNotFoundException::class, $exception); 133 | self::assertSame('A favicon cannot be found for https://example.com', $exception->getMessage()); 134 | } 135 | 136 | /** @test */ 137 | public function default_value_can_be_returned_using_fetchOr_method(): void 138 | { 139 | Http::fake([ 140 | 'https://api.faviconkit.com/example.com' => Http::response('not found', 404), 141 | '*' => Http::response('should not hit here'), 142 | ]); 143 | 144 | $favicon = (new FaviconKitDriver()) 145 | ->useCache(true) 146 | ->fetchOr('https://example.com', 'fallback-to-this'); 147 | 148 | self::assertSame('fallback-to-this', $favicon); 149 | } 150 | 151 | /** @test */ 152 | public function default_value_can_be_returned_using_fetchOr_method_with_a_closure(): void 153 | { 154 | Http::fake([ 155 | 'https://api.faviconkit.com/example.com' => Http::response('not found', 404), 156 | '*' => Http::response('should not hit here'), 157 | ]); 158 | 159 | $favicon = (new FaviconKitDriver()) 160 | ->fetchOr('https://example.com', function () { 161 | return 'fallback-to-this'; 162 | }); 163 | 164 | self::assertSame('fallback-to-this', $favicon); 165 | } 166 | 167 | /** @test */ 168 | public function exception_can_be_thrown_after_attempting_a_fallback(): void 169 | { 170 | Http::fake([ 171 | 'https://api.faviconkit.com/example.com' => Http::response('not found', 404), 172 | '*' => Http::response('should not hit here'), 173 | ]); 174 | 175 | FetcherManager::extend('custom-driver', new NullDriver()); 176 | 177 | $exception = null; 178 | 179 | try { 180 | (new FaviconKitDriver()) 181 | ->throw() 182 | ->withFallback('custom-driver') 183 | ->fetch('https://example.com'); 184 | } catch (\Exception $e) { 185 | $exception = $e; 186 | } 187 | 188 | self::assertInstanceOf(FaviconNotFoundException::class, $exception); 189 | self::assertSame('A favicon cannot be found for https://example.com', $exception->getMessage()); 190 | 191 | self::assertTrue(NullDriver::$flag); 192 | } 193 | 194 | /** @test */ 195 | public function exception_is_thrown_if_the_url_is_invalid(): void 196 | { 197 | Http::fake([ 198 | '*' => Http::response('should not hit here'), 199 | ]); 200 | 201 | $exception = null; 202 | 203 | try { 204 | (new FaviconKitDriver())->fetch('example.com'); 205 | } catch (\Exception $e) { 206 | $exception = $e; 207 | } 208 | 209 | self::assertInstanceOf(InvalidUrlException::class, $exception); 210 | self::assertSame('example.com is not a valid URL', $exception->getMessage()); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /tests/Feature/Drivers/GoogleSharedStuffDriverTest.php: -------------------------------------------------------------------------------- 1 | Http::response('favicon contents here'), 33 | '*' => Http::response('should not hit here'), 34 | ]); 35 | 36 | $favicon = (new GoogleSharedStuffDriver())->fetch($protocol.'://example.com'); 37 | 38 | self::assertSame('https://www.google.com/s2/favicons?domain='.$protocol.'://example.com', $favicon->getFaviconUrl()); 39 | } 40 | 41 | /** @test */ 42 | public function favicon_can_be_fetched_from_the_cache_if_it_already_exists(): void 43 | { 44 | Cache::put( 45 | 'favicon-fetcher.example.com', 46 | [ 47 | 'favicon_url' => 'url-goes-here', 48 | 'icon_size' => null, 49 | 'icon_type' => Favicon::TYPE_ICON_UNKNOWN, 50 | ], 51 | now()->addHour() 52 | ); 53 | 54 | Http::fake([ 55 | '*' => Http::response('should not hit here'), 56 | ]); 57 | 58 | $favicon = (new GoogleSharedStuffDriver())->fetch('https://example.com'); 59 | 60 | self::assertSame('url-goes-here', $favicon->getFaviconUrl()); 61 | } 62 | 63 | /** @test */ 64 | public function favicon_is_not_fetched_from_the_cache_if_it_exists_but_the_use_cache_flag_is_false(): void 65 | { 66 | Cache::put( 67 | 'favicon-fetcher.https://example.com', 68 | 'url-goes-here', 69 | now()->addHour() 70 | ); 71 | 72 | Http::fake([ 73 | 'https://www.google.com/s2/favicons?domain=https://example.com' => Http::response('favicon contents here'), 74 | '*' => Http::response('should not hit here'), 75 | ]); 76 | 77 | $favicon = (new GoogleSharedStuffDriver())->useCache(false)->fetch('https://example.com'); 78 | 79 | self::assertSame('https://www.google.com/s2/favicons?domain=https://example.com', $favicon->getFaviconUrl()); 80 | } 81 | 82 | /** @test */ 83 | public function null_is_returned_if_the_driver_cannot_find_the_favicon(): void 84 | { 85 | Http::fake([ 86 | 'https://www.google.com/s2/favicons?domain=https://example.com' => Http::response('not found', 404), 87 | '*' => Http::response('should not hit here'), 88 | ]); 89 | 90 | $favicon = (new GoogleSharedStuffDriver())->useCache(true)->fetch('https://example.com'); 91 | 92 | self::assertNull($favicon); 93 | } 94 | 95 | /** @test */ 96 | public function fallback_is_attempted_if_the_driver_cannot_find_the_favicon(): void 97 | { 98 | Http::fake([ 99 | 'https://www.google.com/s2/favicons?domain=https://example.com' => Http::response('not found', 404), 100 | '*' => Http::response('should not hit here'), 101 | ]); 102 | 103 | FetcherManager::extend('custom-driver', new CustomDriver()); 104 | 105 | $favicon = (new GoogleSharedStuffDriver()) 106 | ->withFallback('custom-driver') 107 | ->useCache(true) 108 | ->fetch('https://example.com'); 109 | 110 | self::assertSame('favicon-from-default', $favicon->getFaviconUrl()); 111 | } 112 | 113 | /** @test */ 114 | public function exception_is_thrown_if_the_driver_cannot_find_the_favicon_and_the_throw_on_not_found_flag_is_true(): void 115 | { 116 | Http::fake([ 117 | 'https://www.google.com/s2/favicons?domain=https://example.com' => Http::response('not found', 404), 118 | '*' => Http::response('should not hit here'), 119 | ]); 120 | 121 | $exception = null; 122 | 123 | try { 124 | (new GoogleSharedStuffDriver()) 125 | ->throw() 126 | ->useCache(true) 127 | ->fetch('https://example.com'); 128 | } catch (\Exception $e) { 129 | $exception = $e; 130 | } 131 | 132 | self::assertInstanceOf(FaviconNotFoundException::class, $exception); 133 | self::assertSame('A favicon cannot be found for https://example.com', $exception->getMessage()); 134 | } 135 | 136 | /** @test */ 137 | public function default_value_can_be_returned_using_fetchOr_method(): void 138 | { 139 | Http::fake([ 140 | 'https://www.google.com/s2/favicons?domain=https://example.com' => Http::response('not found', 404), 141 | '*' => Http::response('should not hit here'), 142 | ]); 143 | 144 | $favicon = (new GoogleSharedStuffDriver()) 145 | ->useCache(true) 146 | ->fetchOr('https://example.com', 'fallback-to-this'); 147 | 148 | self::assertSame('fallback-to-this', $favicon); 149 | } 150 | 151 | /** @test */ 152 | public function default_value_can_be_returned_using_fetchOr_method_with_a_closure(): void 153 | { 154 | Http::fake([ 155 | 'https://www.google.com/s2/favicons?domain=https://example.com' => Http::response('not found', 404), 156 | '*' => Http::response('should not hit here'), 157 | ]); 158 | 159 | $favicon = (new GoogleSharedStuffDriver()) 160 | ->fetchOr('https://example.com', function () { 161 | return 'fallback-to-this'; 162 | }); 163 | 164 | self::assertSame('fallback-to-this', $favicon); 165 | } 166 | 167 | /** @test */ 168 | public function exception_can_be_thrown_after_attempting_a_fallback(): void 169 | { 170 | Http::fake([ 171 | 'https://www.google.com/s2/favicons?domain=https://example.com' => Http::response('not found', 404), 172 | '*' => Http::response('should not hit here'), 173 | ]); 174 | 175 | FetcherManager::extend('custom-driver', new NullDriver()); 176 | 177 | $exception = null; 178 | 179 | try { 180 | (new GoogleSharedStuffDriver()) 181 | ->throw() 182 | ->withFallback('custom-driver') 183 | ->fetch('https://example.com'); 184 | } catch (\Exception $e) { 185 | $exception = $e; 186 | } 187 | 188 | self::assertInstanceOf(FaviconNotFoundException::class, $exception); 189 | self::assertSame('A favicon cannot be found for https://example.com', $exception->getMessage()); 190 | 191 | self::assertTrue(NullDriver::$flag); 192 | } 193 | 194 | /** @test */ 195 | public function exception_is_thrown_if_the_url_is_invalid(): void 196 | { 197 | Http::fake([ 198 | '*' => Http::response('should not hit here'), 199 | ]); 200 | 201 | $exception = null; 202 | 203 | try { 204 | (new GoogleSharedStuffDriver())->fetch('example.com'); 205 | } catch (\Exception $e) { 206 | $exception = $e; 207 | } 208 | 209 | self::assertInstanceOf(InvalidUrlException::class, $exception); 210 | self::assertSame('example.com is not a valid URL', $exception->getMessage()); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /tests/Feature/Drivers/HttpDriverTest.php: -------------------------------------------------------------------------------- 1 | Http::response($html), 40 | $expectedFaviconUrl => Http::response('favicon contents here'), 41 | ]); 42 | 43 | $favicon = (new HttpDriver())->fetch('https://example.com'); 44 | 45 | self::assertSame($expectedFaviconUrl, $favicon->getFaviconUrl()); 46 | self::assertSame($expectedSize, $favicon->getIconSize()); 47 | self::assertSame($expectedType, $favicon->getIconType()); 48 | } 49 | 50 | /** @test */ 51 | public function favicon_can_be_fetched_if_the_url_has_a_path_and_thelink_element_contains_a_relative_url(): void 52 | { 53 | Http::fake([ 54 | 'https://example.com/blog' => Http::response(self::htmlOptionOne()), 55 | 'https://example.com/icon/is/here.ico' => Http::response('favicon contents here'), 56 | '*' => Http::response('should not hit here'), 57 | ]); 58 | 59 | $favicon = (new HttpDriver())->fetch('https://example.com/blog'); 60 | 61 | self::assertSame('https://example.com/icon/is/here.ico', $favicon->getFaviconUrl()); 62 | } 63 | 64 | /** @test */ 65 | public function favicon_can_be_fetched_from_guessed_url_if_it_cannot_be_found_in_response_html(): void 66 | { 67 | $responseHtml = <<<'HTML' 68 | 69 | 70 | 71 | HTML; 72 | 73 | Http::fake([ 74 | 'https://example.com' => Http::response($responseHtml), 75 | 'https://example.com/favicon.ico' => Http::response('favicon contents here'), 76 | '*' => Http::response('should not hit here'), 77 | ]); 78 | 79 | $favicon = (new HttpDriver())->fetch('https://example.com'); 80 | 81 | self::assertSame('https://example.com/favicon.ico', $favicon->getFaviconUrl()); 82 | } 83 | 84 | /** @test */ 85 | public function favicon_can_be_fetched_from_guessed_url_if_it_cannot_be_found_in_response_html_and_a_relative_url_is_passed(): void 86 | { 87 | $responseHtml = <<<'HTML' 88 | 89 | 90 | 91 | HTML; 92 | 93 | Http::fake([ 94 | 'https://example.com/blog' => Http::response($responseHtml), 95 | 'https://example.com/favicon.ico' => Http::response('favicon contents here'), 96 | '*' => Http::response('should not hit here'), 97 | ]); 98 | 99 | $favicon = (new HttpDriver())->fetch('https://example.com/blog'); 100 | 101 | self::assertSame('https://example.com/favicon.ico', $favicon->getFaviconUrl()); 102 | } 103 | 104 | /** 105 | * @test 106 | * 107 | * @testWith ["https"] 108 | * ["http"] 109 | */ 110 | public function favicon_can_be_fetched_from_driver(string $protocol): void 111 | { 112 | Http::fake([ 113 | 'https://example.com' => Http::response(''), 114 | 'http://example.com' => Http::response(''), 115 | '*' => Http::response('should not hit here'), 116 | ]); 117 | 118 | $favicon = (new HttpDriver())->fetch($protocol.'://example.com'); 119 | 120 | self::assertSame($protocol.'://example.com/icon/favicon.ico', $favicon->getFaviconUrl()); 121 | } 122 | 123 | /** @test */ 124 | public function favicon_can_be_fetched_from_url_with_port(): void 125 | { 126 | Http::fake([ 127 | 'http://example.com:8080' => Http::response(''), 128 | '*' => Http::response('should not hit here'), 129 | ]); 130 | 131 | $favicon = (new HttpDriver())->fetch('http://example.com:8080'); 132 | 133 | self::assertSame('http://example.com:8080/icon/favicon.ico', $favicon->getFaviconUrl()); 134 | } 135 | 136 | /** @test */ 137 | public function favicon_can_be_fetched_from_url_with_query_parameters(): void 138 | { 139 | Http::fake([ 140 | 'http://example.com?query=parameter' => Http::response(''), 141 | '*' => Http::response('should not hit here'), 142 | ]); 143 | 144 | $favicon = (new HttpDriver())->fetch('http://example.com?query=parameter'); 145 | 146 | self::assertSame('http://example.com/icon/favicon.ico', $favicon->getFaviconUrl()); 147 | } 148 | 149 | /** @test */ 150 | public function favicon_can_be_fetched_from_the_cache_if_it_already_exists(): void 151 | { 152 | Cache::put( 153 | 'favicon-fetcher.example.com', 154 | [ 155 | 'favicon_url' => 'url-goes-here', 156 | 'icon_size' => null, 157 | 'icon_type' => Favicon::TYPE_ICON_UNKNOWN, 158 | ], 159 | now()->addHour() 160 | ); 161 | 162 | Http::fake([ 163 | '*' => Http::response('should not hit here'), 164 | ]); 165 | 166 | $favicon = (new HttpDriver())->fetch('https://example.com'); 167 | 168 | self::assertSame('url-goes-here', $favicon->getFaviconUrl()); 169 | self::assertNull($favicon->getIconSize()); 170 | self::assertSame(Favicon::TYPE_ICON_UNKNOWN, $favicon->getIconType()); 171 | } 172 | 173 | /** @test */ 174 | public function favicon_can_be_fetched_from_the_cache_if_it_already_exists_in_the_old_string_format(): void 175 | { 176 | Cache::put( 177 | 'favicon-fetcher.example.com', 178 | 'url-goes-here', 179 | now()->addHour() 180 | ); 181 | 182 | Http::fake([ 183 | '*' => Http::response('should not hit here'), 184 | ]); 185 | 186 | $favicon = (new HttpDriver())->fetch('https://example.com'); 187 | 188 | self::assertSame('url-goes-here', $favicon->getFaviconUrl()); 189 | self::assertNull($favicon->getIconSize()); 190 | self::assertSame(Favicon::TYPE_ICON_UNKNOWN, $favicon->getIconType()); 191 | } 192 | 193 | /** @test */ 194 | public function favicon_is_not_fetched_from_the_cache_if_it_exists_but_the_use_cache_flag_is_false(): void 195 | { 196 | Cache::put( 197 | 'favicon-fetcher.https://example.com', 198 | 'url-goes-here', 199 | now()->addHour() 200 | ); 201 | 202 | Http::fake([ 203 | 'https://example.com' => Http::response(''), 204 | '*' => Http::response('should not hit here'), 205 | ]); 206 | 207 | $favicon = (new HttpDriver())->useCache(false)->fetch('https://example.com'); 208 | 209 | self::assertSame('https://example.com/icon/favicon.ico', $favicon->getFaviconUrl()); 210 | } 211 | 212 | /** @test */ 213 | public function null_is_returned_if_the_driver_cannot_find_the_favicon(): void 214 | { 215 | Http::fake([ 216 | 'https://example.com/*' => Http::response('not found', 404), 217 | '*' => Http::response('should not hit here'), 218 | ]); 219 | 220 | $favicon = (new HttpDriver())->useCache(true)->fetch('https://example.com'); 221 | 222 | self::assertNull($favicon); 223 | } 224 | 225 | /** @test */ 226 | public function fallback_is_attempted_if_the_driver_cannot_find_the_favicon(): void 227 | { 228 | Http::fake([ 229 | 'https://example.com/*' => Http::response('not found', 404), 230 | '*' => Http::response('should not hit here'), 231 | ]); 232 | 233 | FetcherManager::extend('custom-driver', new CustomDriver()); 234 | 235 | $favicon = (new HttpDriver()) 236 | ->withFallback('custom-driver') 237 | ->useCache(true) 238 | ->fetch('https://example.com'); 239 | 240 | self::assertSame('favicon-from-default', $favicon->getFaviconUrl()); 241 | } 242 | 243 | /** @test */ 244 | public function exception_is_thrown_if_the_driver_cannot_find_the_favicon_and_the_throw_on_not_found_flag_is_true(): void 245 | { 246 | Http::fake([ 247 | 'https://example.com/*' => Http::response('not found', 404), 248 | '*' => Http::response('should not hit here'), 249 | ]); 250 | 251 | $exception = null; 252 | 253 | try { 254 | (new HttpDriver()) 255 | ->throw() 256 | ->useCache(true) 257 | ->fetch('https://example.com'); 258 | } catch (\Exception $e) { 259 | $exception = $e; 260 | } 261 | 262 | self::assertInstanceOf(FaviconNotFoundException::class, $exception); 263 | self::assertSame('A favicon cannot be found for https://example.com', $exception->getMessage()); 264 | } 265 | 266 | /** @test */ 267 | public function default_value_can_be_returned_using_fetchOr_method(): void 268 | { 269 | Http::fake([ 270 | 'https://example.com/*' => Http::response('not found', 404), 271 | '*' => Http::response('should not hit here'), 272 | ]); 273 | 274 | $favicon = (new HttpDriver()) 275 | ->useCache(true) 276 | ->fetchOr('https://example.com', 'fallback-to-this'); 277 | 278 | self::assertSame('fallback-to-this', $favicon); 279 | } 280 | 281 | /** @test */ 282 | public function default_value_can_be_returned_using_fetchOr_method_with_a_closure(): void 283 | { 284 | Http::fake([ 285 | 'https://example.com/*' => Http::response('not found', 404), 286 | '*' => Http::response('should not hit here'), 287 | ]); 288 | 289 | $favicon = (new HttpDriver()) 290 | ->fetchOr('https://example.com', function () { 291 | return 'fallback-to-this'; 292 | }); 293 | 294 | self::assertSame('fallback-to-this', $favicon); 295 | } 296 | 297 | /** @test */ 298 | public function exception_can_be_thrown_after_attempting_a_fallback(): void 299 | { 300 | Http::fake([ 301 | 'https://example.com/*' => Http::response('not found', 404), 302 | '*' => Http::response('should not hit here'), 303 | ]); 304 | 305 | FetcherManager::extend('custom-driver', new NullDriver()); 306 | 307 | $exception = null; 308 | 309 | try { 310 | (new HttpDriver()) 311 | ->throw() 312 | ->withFallback('custom-driver') 313 | ->fetch('https://example.com'); 314 | } catch (\Exception $e) { 315 | $exception = $e; 316 | } 317 | 318 | self::assertInstanceOf(FaviconNotFoundException::class, $exception); 319 | self::assertSame('A favicon cannot be found for https://example.com', $exception->getMessage()); 320 | 321 | self::assertTrue(NullDriver::$flag); 322 | } 323 | 324 | /** @test */ 325 | public function exception_is_thrown_if_the_url_is_invalid(): void 326 | { 327 | Http::fake([ 328 | '*' => Http::response('should not hit here'), 329 | ]); 330 | 331 | $exception = null; 332 | 333 | try { 334 | (new HttpDriver())->fetch('example.com'); 335 | } catch (\Exception $e) { 336 | $exception = $e; 337 | } 338 | 339 | self::assertInstanceOf(InvalidUrlException::class, $exception); 340 | self::assertSame('example.com is not a valid URL', $exception->getMessage()); 341 | } 342 | 343 | /** 344 | * @test 345 | * 346 | * @dataProvider allFaviconLinksInHtmlProvider 347 | */ 348 | public function all_icons_for_a_url_can_be_fetched(string $html, $expectedFaviconCollection): void 349 | { 350 | Http::fake([ 351 | 'https://example.com' => Http::response($html), 352 | '*' => Http::response('should not hit here'), 353 | ]); 354 | 355 | $favicons = (new HttpDriver())->fetchAll('https://example.com'); 356 | 357 | self::assertCount($expectedFaviconCollection->count(), $favicons); 358 | 359 | foreach ($favicons as $index => $favicon) { 360 | self::assertSame($expectedFaviconCollection[$index]->getFaviconUrl(), $favicon->getFaviconUrl()); 361 | self::assertSame($expectedFaviconCollection[$index]->getIconType(), $favicon->getIconType()); 362 | self::assertSame($expectedFaviconCollection[$index]->getIconSize(), $favicon->getIconSize()); 363 | } 364 | } 365 | 366 | /** @test */ 367 | public function favicon_can_be_fetched_from_guessed_url_if_it_cannot_be_found_in_response_html_when_trying_to_get_all_icons(): void 368 | { 369 | $responseHtml = <<<'HTML' 370 | 371 | 372 | 373 | HTML; 374 | 375 | Http::fake([ 376 | 'https://example.com' => Http::response($responseHtml), 377 | 'https://example.com/favicon.ico' => Http::response('favicon contents here'), 378 | '*' => Http::response('should not hit here'), 379 | ]); 380 | 381 | $favicons = (new HttpDriver())->fetchAll('https://example.com'); 382 | 383 | self::assertCount(1, $favicons); 384 | self::assertSame($favicons->first()->getFaviconUrl(), 'https://example.com/favicon.ico'); 385 | } 386 | 387 | /** @test */ 388 | public function empty_favicon_collection_is_returned_if_the_url_cannot_be_reached(): void 389 | { 390 | Http::fake([ 391 | 'https://example.com' => Http::response('not found', 404), 392 | 'https://example.com/favicon.ico' => Http::response('not found', 404), 393 | '*' => Http::response('should not hit here'), 394 | ]); 395 | 396 | $favicons = (new HttpDriver())->fetchAll('https://example.com'); 397 | 398 | self::assertCount(0, $favicons); 399 | } 400 | 401 | /** @test */ 402 | public function empty_favicon_collection_is_returned_if_no_icons_can_be_found_for_a_url(): void 403 | { 404 | $responseHtml = <<<'HTML' 405 | 406 | 407 | 408 | HTML; 409 | 410 | Http::fake([ 411 | 'https://example.com' => Http::response($responseHtml), 412 | 'https://example.com/favicon.ico' => Http::response('not found', 404), 413 | '*' => Http::response('should not hit here'), 414 | ]); 415 | 416 | $favicons = (new HttpDriver())->fetchAll('https://example.com'); 417 | 418 | self::assertCount(0, $favicons); 419 | } 420 | 421 | /** @test */ 422 | public function error_is_thrown_if_trying_to_find_all_the_favicons_for_an_invalid_url(): void 423 | { 424 | Http::fake([ 425 | '*' => Http::response('should not hit here'), 426 | ]); 427 | 428 | $exception = null; 429 | 430 | try { 431 | (new HttpDriver())->fetchAll('example.com'); 432 | } catch (\Exception $e) { 433 | $exception = $e; 434 | } 435 | 436 | self::assertInstanceOf(InvalidUrlException::class, $exception); 437 | self::assertSame('example.com is not a valid URL', $exception->getMessage()); 438 | } 439 | 440 | /** @test */ 441 | public function error_is_thrown_if_no_icons_can_be_found_for_a_url_and_the_throw_on_not_found_flag_is_true(): void 442 | { 443 | $responseHtml = <<<'HTML' 444 | 445 | 446 | 447 | HTML; 448 | 449 | Http::fake([ 450 | 'https://example.com' => Http::response($responseHtml), 451 | '*' => Http::response('should not hit here'), 452 | ]); 453 | 454 | $exception = null; 455 | 456 | try { 457 | (new HttpDriver()) 458 | ->throw() 459 | ->fetchAll('https://example.com'); 460 | } catch (\Exception $e) { 461 | $exception = $e; 462 | } 463 | 464 | self::assertInstanceOf(FaviconNotFoundException::class, $exception); 465 | self::assertSame('A favicon cannot be found for https://example.com', $exception->getMessage()); 466 | } 467 | 468 | /** @test */ 469 | public function all_favicon_for_a_url_can_be_fetched_from_the_cache_if_it_already_exists(): void 470 | { 471 | Cache::put( 472 | 'favicon-fetcher.example.com.collection', 473 | [ 474 | [ 475 | 'favicon_url' => 'url-goes-here', 476 | 'icon_size' => null, 477 | 'icon_type' => Favicon::TYPE_ICON_UNKNOWN, 478 | ], 479 | [ 480 | 'favicon_url' => 'url-goes-here-1', 481 | 'icon_size' => 100, 482 | 'icon_type' => Favicon::TYPE_ICON, 483 | ], 484 | [ 485 | 'favicon_url' => 'url-goes-here-1.com', 486 | 'icon_size' => 192, 487 | 'icon_type' => Favicon::TYPE_APPLE_TOUCH_ICON, 488 | ], 489 | ], 490 | now()->addHour(), 491 | ); 492 | 493 | Http::fake([ 494 | '*' => Http::response('should not hit here'), 495 | ]); 496 | 497 | $favicons = (new HttpDriver())->fetchAll('https://example.com'); 498 | 499 | self::assertCount(3, $favicons); 500 | 501 | self::assertSame('url-goes-here', $favicons->first()->getFaviconUrl()); 502 | self::assertSame('url-goes-here-1', $favicons->skip(1)->first()->getFaviconUrl()); 503 | self::assertSame('url-goes-here-1.com', $favicons->skip(2)->first()->getFaviconUrl()); 504 | 505 | self::assertNull($favicons->first()->getIconSize()); 506 | self::assertSame(100, $favicons->skip(1)->first()->getIconSize()); 507 | self::assertSame(192, $favicons->skip(2)->first()->getIconSize()); 508 | 509 | self::assertSame(Favicon::TYPE_ICON_UNKNOWN, $favicons->first()->getIconType()); 510 | self::assertSame(Favicon::TYPE_ICON, $favicons->skip(1)->first()->getIconType()); 511 | self::assertSame(Favicon::TYPE_APPLE_TOUCH_ICON, $favicons->skip(2)->first()->getIconType()); 512 | } 513 | 514 | /** @test */ 515 | public function all_favicons_for_a_url_are_not_fetched_from_the_cache_if_it_exists_but_the_use_cache_flag_is_false(): void 516 | { 517 | Cache::put( 518 | 'favicon-fetcher.example.com.collection', 519 | [ 520 | [ 521 | 'favicon_url' => 'url-goes-here', 522 | 'icon_size' => null, 523 | 'icon_type' => Favicon::TYPE_ICON_UNKNOWN, 524 | ], 525 | [ 526 | 'favicon_url' => 'url-goes-here-1', 527 | 'icon_size' => 100, 528 | 'icon_type' => Favicon::TYPE_ICON, 529 | ], 530 | [ 531 | 'favicon_url' => 'url-goes-here-1.com', 532 | 'icon_size' => 192, 533 | 'icon_type' => Favicon::TYPE_APPLE_TOUCH_ICON, 534 | ], 535 | ], 536 | now()->addHour(), 537 | ); 538 | 539 | Http::fake([ 540 | 'https://example.com' => Http::response(''), 541 | '*' => Http::response('should not hit here'), 542 | ]); 543 | 544 | $favicons = (new HttpDriver())->useCache(false)->fetchAll('https://example.com'); 545 | 546 | self::assertCount(1, $favicons); 547 | 548 | self::assertSame('https://example.com/icon/favicon.ico', $favicons->first()->getFaviconUrl()); 549 | } 550 | 551 | /** @test */ 552 | public function favicons_can_be_returned_using_the_fetchAllOr_method(): void 553 | { 554 | Http::fake([ 555 | 'https://example.com' => Http::response(self::htmlOptionOne()), 556 | '*' => Http::response('should not hit here'), 557 | ]); 558 | 559 | $favicons = (new HttpDriver())->fetchAllOr('https://example.com', 'should not fallback to this'); 560 | 561 | self::assertCount(1, $favicons); 562 | 563 | self::assertSame('https://example.com/icon/is/here.ico', $favicons->first()->getFaviconUrl()); 564 | self::assertSame(Favicon::TYPE_ICON, $favicons->first()->getIconType()); 565 | self::assertSame(null, $favicons->first()->getIconSize()); 566 | } 567 | 568 | /** @test */ 569 | public function default_value_can_be_returned_using_fetchAllOr_method(): void 570 | { 571 | Http::fake([ 572 | 'https://example.com/*' => Http::response('not found', 404), 573 | '*' => Http::response('should not hit here'), 574 | ]); 575 | 576 | $favicon = (new HttpDriver()) 577 | ->useCache(true) 578 | ->fetchAllOr('https://example.com', 'fallback-to-this'); 579 | 580 | self::assertSame('fallback-to-this', $favicon); 581 | } 582 | 583 | /** @test */ 584 | public function default_value_can_be_returned_using_fetchAllOr_method_with_a_closure(): void 585 | { 586 | Http::fake([ 587 | 'https://example.com/*' => Http::response('not found', 404), 588 | '*' => Http::response('should not hit here'), 589 | ]); 590 | 591 | $favicon = (new HttpDriver()) 592 | ->fetchAllOr('https://example.com', function () { 593 | return 'fallback-to-this'; 594 | }); 595 | 596 | self::assertSame('fallback-to-this', $favicon); 597 | } 598 | 599 | /** @test */ 600 | public function can_set_the_user_agent_when_fetching() 601 | { 602 | Http::fake(); 603 | 604 | $driver = new HttpDriver(); 605 | 606 | // No user agent set. 607 | $driver->fetch('https://example.com'); 608 | 609 | Http::assertSent(function (Request $request) { 610 | return $request->hasHeader('User-Agent', 'GuzzleHttp/7'); 611 | }); 612 | 613 | // Custom user agent. 614 | config()->set('favicon-fetcher.user_agent', 'test-user-agent'); 615 | 616 | $driver->fetch('https://example.com'); 617 | 618 | Http::assertSent(function (Request $request) { 619 | return $request->hasHeader('User-Agent', 'test-user-agent'); 620 | }); 621 | } 622 | 623 | /** @test */ 624 | public function null_is_returned_if_using_fetch_and_the_link_has_no_href(): void 625 | { 626 | $responseHtml = <<<'HTML' 627 | 628 | 629 | Dummy title 630 | 631 | 632 | 633 | 634 | HTML; 635 | 636 | Http::preventStrayRequests(); 637 | 638 | Http::fake([ 639 | 'https://example.com' => Http::response($responseHtml), 640 | 'https://example.com/favicon.ico' => Http::response(status: 404), 641 | ]); 642 | 643 | $favicon = (new HttpDriver())->fetch('https://example.com'); 644 | 645 | self::assertNull($favicon); 646 | } 647 | 648 | /** @test */ 649 | public function null_is_returned_if_using_fetchAll_and_the_link_has_no_href(): void 650 | { 651 | $responseHtml = <<<'HTML' 652 | 653 | 654 | Dummy title 655 | 656 | 657 | 658 | 659 | HTML; 660 | 661 | Http::preventStrayRequests(); 662 | 663 | Http::fake([ 664 | 'https://example.com' => Http::response($responseHtml), 665 | 'https://example.com/favicon.ico' => Http::response(status: 404), 666 | ]); 667 | 668 | $favicons = (new HttpDriver())->fetchAll('https://example.com'); 669 | 670 | self::assertCount(0, $favicons); 671 | } 672 | 673 | public static function allFaviconLinksInHtmlProvider(): array 674 | { 675 | return [ 676 | [ 677 | self::htmlOptionOne(), 678 | FaviconCollection::make([ 679 | (new Favicon('https://example.com', 'https://example.com/icon/is/here.ico'))->setIconType(Favicon::TYPE_ICON), 680 | ]), 681 | ], 682 | [ 683 | self::htmlOptionTwo(), 684 | FaviconCollection::make([ 685 | (new Favicon('https://example.com', 'https://example.com/icon/is/here.ico'))->setIconType(Favicon::TYPE_ICON), 686 | ]), 687 | ], 688 | [ 689 | self::htmlOptionThree(), 690 | FaviconCollection::make([ 691 | (new Favicon('https://example.com', 'https://example.com/icon/is/here.ico'))->setIconType(Favicon::TYPE_SHORTCUT_ICON), 692 | ]), 693 | ], 694 | [ 695 | self::htmlOptionFour(), 696 | FaviconCollection::make([ 697 | (new Favicon('https://example.com', 'https://example.com/favicon/favicon-32x32.png'))->setIconSize(null)->setIconType(Favicon::TYPE_ICON), 698 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-57x57.png'))->setIconSize(57)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 699 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-60x60.png'))->setIconSize(60)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 700 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-72x72.png'))->setIconSize(72)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 701 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-72x72.png'))->setIconSize(76)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 702 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-76x76.png'))->setIconSize(114)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 703 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-120x120.png'))->setIconSize(120)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 704 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-144x144.png'))->setIconSize(144)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 705 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-152x152.png'))->setIconSize(152)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 706 | (new Favicon('https://example.com', 'https://example.com/favicon/apple-icon-180x180.png'))->setIconSize(180)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 707 | (new Favicon('https://example.com', 'https://example.com/favicon/android-icon-192x192.png'))->setIconSize(192)->setIconType(Favicon::TYPE_ICON), 708 | ]), 709 | ], 710 | [ 711 | self::htmlOptionFive(), 712 | FaviconCollection::make([ 713 | (new Favicon('https://example.com', 'https://example.com/icon/is/here.ico'))->setIconType(Favicon::TYPE_SHORTCUT_ICON), 714 | ]), 715 | ], 716 | [ 717 | self::htmlOptionSix(), 718 | FaviconCollection::make([ 719 | (new Favicon('https://example.com', 'https://example.com/images/apple-icon-180x180.png'))->setIconSize(180)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 720 | (new Favicon('https://example.com', 'https://example.com/images/favicon.ico'))->setIconType(Favicon::TYPE_SHORTCUT_ICON), 721 | ]), 722 | ], 723 | [ 724 | self::htmlOptionSeven(), 725 | FaviconCollection::make([ 726 | (new Favicon('https://example.com', 'https://example.com/images/apple-icon-180x180.png'))->setIconSize(180)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 727 | (new Favicon('https://example.com', 'https://example.com/images/favicon.ico'))->setIconType(Favicon::TYPE_SHORTCUT_ICON), 728 | ]), 729 | ], 730 | [ 731 | self::htmlOptionEight(), 732 | FaviconCollection::make([ 733 | (new Favicon('https://example.com', 'https://example.com/images/apple-icon-180x180.png'))->setIconSize(180)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 734 | (new Favicon('https://example.com', 'https://example.com/images/favicon.ico'))->setIconType(Favicon::TYPE_ICON), 735 | ]), 736 | ], 737 | [ 738 | self::htmlOptionNine(), 739 | FaviconCollection::make([ 740 | (new Favicon('https://example.com', 'https://example.com/images/apple-icon-180x180.png'))->setIconSize(180)->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 741 | (new Favicon('https://example.com', 'https://example.com/images/favicon.ico'))->setIconType(Favicon::TYPE_ICON), 742 | ]), 743 | ], 744 | [ 745 | self::htmlOptionTen(), 746 | FaviconCollection::make([ 747 | (new Favicon('https://example.com', 'https://www.example.com/favicon123.png'))->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON), 748 | (new Favicon('https://example.com', 'https://www.example.com/favicon123.ico'))->setIconType(Favicon::TYPE_SHORTCUT_ICON), 749 | ]), 750 | ], 751 | [ 752 | self::htmlOptionEleven(), 753 | FaviconCollection::make([ 754 | (new Favicon('https://example.com', 'https://example.com/apple-icon-57x57.png'))->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON)->setIconSize(57), 755 | (new Favicon('https://example.com', 'https://example.com/apple-icon-60x60.png'))->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON)->setIconSize(60), 756 | (new Favicon('https://example.com', 'https://example.com/apple-icon-72x72.png'))->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON)->setIconSize(72), 757 | (new Favicon('https://example.com', 'https://example.com/apple-icon-76x76.png'))->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON)->setIconSize(76), 758 | (new Favicon('https://example.com', 'https://example.com/apple-icon-114x114.png'))->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON)->setIconSize(114), 759 | (new Favicon('https://example.com', 'https://example.com/apple-icon-120x120.png'))->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON)->setIconSize(120), 760 | (new Favicon('https://example.com', 'https://example.com/apple-icon-144x144.png'))->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON)->setIconSize(144), 761 | (new Favicon('https://example.com', 'https://example.com/apple-icon-152x152.png'))->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON)->setIconSize(152), 762 | (new Favicon('https://example.com', 'https://example.com/apple-icon-200x200.png'))->setIconType(Favicon::TYPE_APPLE_TOUCH_ICON)->setIconSize(200), 763 | (new Favicon('https://example.com', 'https://example.com/android-icon-192x192.png'))->setIconType(Favicon::TYPE_ICON)->setIconSize(192), 764 | (new Favicon('https://example.com', 'https://example.com/favicon-32x32.png'))->setIconType(Favicon::TYPE_ICON)->setIconSize(32), 765 | (new Favicon('https://example.com', 'https://example.com/favicon-96x96.png'))->setIconType(Favicon::TYPE_ICON)->setIconSize(96), 766 | ]), 767 | ], 768 | [ 769 | self::htmlOptionThirteen(), 770 | FaviconCollection::make([ 771 | (new Favicon('https://example.com', 'https://example.com/favicon-96x96.png'))->setIconType(Favicon::TYPE_ICON)->setIconSize(96), 772 | ]), 773 | ], 774 | ]; 775 | } 776 | 777 | public static function faviconLinksInHtmlProvider(): array 778 | { 779 | return [ 780 | [self::htmlOptionOne(), 'https://example.com/icon/is/here.ico', null, Favicon::TYPE_ICON], 781 | [self::htmlOptionTwo(), 'https://example.com/icon/is/here.ico', null, Favicon::TYPE_ICON], 782 | [self::htmlOptionThree(), 'https://example.com/icon/is/here.ico', null, Favicon::TYPE_SHORTCUT_ICON], 783 | [self::htmlOptionFour(), 'https://example.com/favicon/favicon-32x32.png', null, Favicon::TYPE_ICON], 784 | [self::htmlOptionFive(), 'https://example.com/icon/is/here.ico', null, Favicon::TYPE_SHORTCUT_ICON], 785 | [self::htmlOptionSix(), 'https://example.com/images/favicon.ico', null, Favicon::TYPE_SHORTCUT_ICON], 786 | [self::htmlOptionSeven(), 'https://example.com/images/favicon.ico', null, Favicon::TYPE_SHORTCUT_ICON], 787 | [self::htmlOptionEight(), 'https://example.com/images/favicon.ico', null, Favicon::TYPE_ICON], 788 | [self::htmlOptionNine(), 'https://example.com/images/favicon.ico', null, Favicon::TYPE_ICON], 789 | [self::htmlOptionTen(), 'https://www.example.com/favicon123.ico', null, Favicon::TYPE_SHORTCUT_ICON], 790 | [self::htmlOptionEleven(), 'https://example.com/android-icon-192x192.png', 192, Favicon::TYPE_ICON], 791 | [self::htmlOptionTwelve(), 'https://example.com/android-icon-192x192.png', 192, Favicon::TYPE_ICON], 792 | [self::htmlOptionThirteen(), 'https://example.com/favicon-96x96.png', 96, Favicon::TYPE_ICON], 793 | ]; 794 | } 795 | 796 | private static function htmlOptionOne(): string 797 | { 798 | return <<<'HTML' 799 | 800 | 801 | 802 | HTML; 803 | } 804 | 805 | private static function htmlOptionTwo(): string 806 | { 807 | return <<<'HTML' 808 | 809 | 810 | 811 | HTML; 812 | } 813 | 814 | private static function htmlOptionThree(): string 815 | { 816 | return <<<'HTML' 817 | 818 | 819 | 820 | HTML; 821 | } 822 | 823 | private static function htmlOptionFour(): string 824 | { 825 | return <<<'HTML' 826 | 827 | 828 | 829 | HTML; 830 | } 831 | 832 | private static function htmlOptionFive(): string 833 | { 834 | return <<<'HTML' 835 | 836 | 837 | 838 | HTML; 839 | } 840 | 841 | private static function htmlOptionSix(): string 842 | { 843 | return <<<'HTML' 844 | Title here 868 | 869 | 870 | HTML; 871 | } 872 | 873 | private static function htmlOptionEight(): string 874 | { 875 | return <<<'HTML' 876 | Title here 900 | 901 | 902 | HTML; 903 | } 904 | 905 | private static function htmlOptionTen(): string 906 | { 907 | return <<<'HTML' 908 | 909 | Test Title 910 | 911 | 912 | 913 | 914 | 915 | 916 | HTML; 917 | } 918 | 919 | private static function htmlOptionEleven(): string 920 | { 921 | return <<<'HTML' 922 | 923 | 924 | 925 | 926 | 927 | 928 | 929 | 930 | 931 | 932 | 933 | 934 | 935 | 936 | 937 | 938 | 939 | 940 | 941 | Dummy title 942 | 943 | HTML; 944 | } 945 | 946 | private static function htmlOptionTwelve(): string 947 | { 948 | return <<<'HTML' 949 | 950 | 951 | 952 | 953 | 954 | 955 | 956 | 957 | 958 | 959 | 960 | 961 | 962 | 963 | 964 | 965 | 966 | 967 | 968 | Dummy title 969 | 970 | HTML; 971 | } 972 | 973 | private static function htmlOptionThirteen(): string 974 | { 975 | return <<<'HTML' 976 | 977 | 978 | Dummy title 979 | 980 | 981 | 982 | 983 | 984 | HTML; 985 | } 986 | } 987 | -------------------------------------------------------------------------------- /tests/Feature/Drivers/UnavatarDriverTest.php: -------------------------------------------------------------------------------- 1 | Http::response('favicon contents here'), 33 | '*' => Http::response('should not hit here'), 34 | ]); 35 | 36 | $favicon = (new UnavatarDriver())->fetch($protocol.'://example.com'); 37 | 38 | self::assertSame('https://unavatar.io/example.com?fallback=false', $favicon->getFaviconUrl()); 39 | } 40 | 41 | /** @test */ 42 | public function favicon_can_be_fetched_from_the_cache_if_it_already_exists(): void 43 | { 44 | Cache::put( 45 | 'favicon-fetcher.example.com', 46 | [ 47 | 'favicon_url' => 'url-goes-here', 48 | 'icon_size' => null, 49 | 'icon_type' => Favicon::TYPE_ICON_UNKNOWN, 50 | ], 51 | now()->addHour() 52 | ); 53 | 54 | Http::fake([ 55 | '*' => Http::response('should not hit here'), 56 | ]); 57 | 58 | $favicon = (new UnavatarDriver())->fetch('https://example.com'); 59 | 60 | self::assertSame('url-goes-here', $favicon->getFaviconUrl()); 61 | } 62 | 63 | /** @test */ 64 | public function favicon_is_not_fetched_from_the_cache_if_it_exists_but_the_use_cache_flag_is_false(): void 65 | { 66 | Cache::put( 67 | 'favicon-fetcher.https://example.com', 68 | 'url-goes-here', 69 | now()->addHour() 70 | ); 71 | 72 | Http::fake([ 73 | 'https://unavatar.io/example.com' => Http::response('favicon contents here'), 74 | '*' => Http::response('should not hit here'), 75 | ]); 76 | 77 | $favicon = (new UnavatarDriver())->useCache(false)->fetch('https://example.com'); 78 | 79 | self::assertSame('https://unavatar.io/example.com?fallback=false', $favicon->getFaviconUrl()); 80 | } 81 | 82 | /** @test */ 83 | public function null_is_returned_if_the_driver_cannot_find_the_favicon(): void 84 | { 85 | Http::fake([ 86 | 'https://unavatar.io/example.com?fallback=false' => Http::response('not found', 404), 87 | '*' => Http::response('should not hit here'), 88 | ]); 89 | 90 | $favicon = (new UnavatarDriver())->useCache(true)->fetch('https://example.com'); 91 | 92 | self::assertNull($favicon); 93 | } 94 | 95 | /** @test */ 96 | public function fallback_is_attempted_if_the_driver_cannot_find_the_favicon(): void 97 | { 98 | Http::fake([ 99 | 'https://unavatar.io/example.com?fallback=false' => Http::response('not found', 404), 100 | '*' => Http::response('should not hit here'), 101 | ]); 102 | 103 | FetcherManager::extend('custom-driver', new CustomDriver()); 104 | 105 | $favicon = (new UnavatarDriver()) 106 | ->withFallback('custom-driver') 107 | ->useCache(true) 108 | ->fetch('https://example.com'); 109 | 110 | self::assertSame('favicon-from-default', $favicon->getFaviconUrl()); 111 | } 112 | 113 | /** @test */ 114 | public function exception_is_thrown_if_the_driver_cannot_find_the_favicon_and_the_throw_on_not_found_flag_is_true(): void 115 | { 116 | Http::fake([ 117 | 'https://unavatar.io/example.com?fallback=false' => Http::response('not found', 404), 118 | '*' => Http::response('should not hit here'), 119 | ]); 120 | 121 | $exception = null; 122 | 123 | try { 124 | (new UnavatarDriver()) 125 | ->throw() 126 | ->useCache(true) 127 | ->fetch('https://example.com'); 128 | } catch (\Exception $e) { 129 | $exception = $e; 130 | } 131 | 132 | self::assertInstanceOf(FaviconNotFoundException::class, $exception); 133 | self::assertSame('A favicon cannot be found for https://example.com', $exception->getMessage()); 134 | } 135 | 136 | /** @test */ 137 | public function default_value_can_be_returned_using_fetchOr_method(): void 138 | { 139 | Http::fake([ 140 | 'https://unavatar.io/example.com?fallback=false' => Http::response('not found', 404), 141 | '*' => Http::response('should not hit here'), 142 | ]); 143 | 144 | $favicon = (new UnavatarDriver()) 145 | ->useCache(true) 146 | ->fetchOr('https://example.com', 'fallback-to-this'); 147 | 148 | self::assertSame('fallback-to-this', $favicon); 149 | } 150 | 151 | /** @test */ 152 | public function default_value_can_be_returned_using_fetchOr_method_with_a_closure(): void 153 | { 154 | Http::fake([ 155 | 'https://unavatar.io/example.com?fallback=false' => Http::response('not found', 404), 156 | '*' => Http::response('should not hit here'), 157 | ]); 158 | 159 | $favicon = (new UnavatarDriver()) 160 | ->fetchOr('https://example.com', function () { 161 | return 'fallback-to-this'; 162 | }); 163 | 164 | self::assertSame('fallback-to-this', $favicon); 165 | } 166 | 167 | /** @test */ 168 | public function exception_can_be_thrown_after_attempting_a_fallback(): void 169 | { 170 | Http::fake([ 171 | 'https://unavatar.io/example.com?fallback=false' => Http::response('not found', 404), 172 | '*' => Http::response('should not hit here'), 173 | ]); 174 | 175 | FetcherManager::extend('custom-driver', new NullDriver()); 176 | 177 | $exception = null; 178 | 179 | try { 180 | (new UnavatarDriver()) 181 | ->throw() 182 | ->withFallback('custom-driver') 183 | ->fetch('https://example.com'); 184 | } catch (\Exception $e) { 185 | $exception = $e; 186 | } 187 | 188 | self::assertInstanceOf(FaviconNotFoundException::class, $exception); 189 | self::assertSame('A favicon cannot be found for https://example.com', $exception->getMessage()); 190 | 191 | self::assertTrue(NullDriver::$flag); 192 | } 193 | 194 | /** @test */ 195 | public function exception_is_thrown_if_the_url_is_invalid(): void 196 | { 197 | Http::fake([ 198 | '*' => Http::response('should not hit here'), 199 | ]); 200 | 201 | $exception = null; 202 | 203 | try { 204 | (new UnavatarDriver())->fetch('example.com'); 205 | } catch (\Exception $e) { 206 | $exception = $e; 207 | } 208 | 209 | self::assertInstanceOf(InvalidUrlException::class, $exception); 210 | self::assertSame('example.com is not a valid URL', $exception->getMessage()); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /tests/Feature/FaviconTest.php: -------------------------------------------------------------------------------- 1 | getFaviconUrl()); 32 | } 33 | 34 | /** @test */ 35 | public function favicon_contents_can_be_returned(): void 36 | { 37 | Http::fake([ 38 | 'https://example.com/favicon.ico' => Http::response('favicon contents here'), 39 | '*' => Http::response('should not hit here'), 40 | ]); 41 | 42 | $favicon = new Favicon( 43 | url: 'https://example.com', 44 | faviconUrl: 'https://example.com/favicon.ico', 45 | ); 46 | 47 | self::assertSame('favicon contents here', $favicon->content()); 48 | } 49 | 50 | /** @test */ 51 | public function url_can_be_returned(): void 52 | { 53 | $favicon = new Favicon( 54 | url: 'https://example.com', 55 | faviconUrl: 'https://example.com/favicon.ico', 56 | ); 57 | 58 | self::assertSame('https://example.com', $favicon->getUrl()); 59 | } 60 | 61 | /** @test */ 62 | public function retrieved_from_cache_value_can_be_returned_if_the_favicon_was_retrieved_from_the_cache(): void 63 | { 64 | $favicon = new Favicon( 65 | url: 'https://example.com', 66 | faviconUrl: 'https://example.com/favicon.ico', 67 | retrievedFromCache: true, 68 | ); 69 | 70 | self::assertTrue($favicon->retrievedFromCache()); 71 | } 72 | 73 | /** @test */ 74 | public function retrieved_from_cache_value_can_be_returned_if_the_favicon_was_not_retrieved_from_the_cache(): void 75 | { 76 | $favicon = new Favicon( 77 | url: 'https://example.com', 78 | faviconUrl: 'https://example.com/favicon.ico', 79 | ); 80 | 81 | self::assertFalse($favicon->retrievedFromCache()); 82 | } 83 | 84 | /** @test */ 85 | public function favicon_can_be_cached_if_it_is_not_already_cached(): void 86 | { 87 | Carbon::setTestNow(now()); 88 | 89 | $expectedTtl = now()->addMinute(); 90 | 91 | Cache::shouldReceive('put') 92 | ->withArgs([ 93 | 'favicon-fetcher.example.com', 94 | [ 95 | 'favicon_url' => 'https://example.com/favicon.ico', 96 | 'icon_size' => null, 97 | 'icon_type' => Favicon::TYPE_ICON_UNKNOWN, 98 | ], 99 | Mockery::on(fn (CarbonInterface $ttl): bool => $ttl->eq($expectedTtl)), 100 | ]) 101 | ->once(); 102 | 103 | (new Favicon( 104 | url: 'https://example.com', 105 | faviconUrl: 'https://example.com/favicon.ico', 106 | ))->cache($expectedTtl); 107 | } 108 | 109 | /** @test */ 110 | public function favicon_cannot_be_cached_if_it_is_already_cached(): void 111 | { 112 | Carbon::setTestNow(now()); 113 | 114 | $expectedTtl = now()->addMinute(); 115 | 116 | Cache::shouldReceive('put')->never(); 117 | 118 | (new Favicon( 119 | url: 'https://example.com', 120 | faviconUrl: 'https://example.com/favicon.ico', 121 | retrievedFromCache: true, 122 | ))->cache($expectedTtl); 123 | } 124 | 125 | /** @test */ 126 | public function favicon_can_be_cached_if_it_is_already_cached_and_the_force_flag_is_passed(): void 127 | { 128 | Carbon::setTestNow(now()); 129 | 130 | $expectedTtl = now()->addMinute(); 131 | 132 | Cache::shouldReceive('put') 133 | ->withArgs([ 134 | 'favicon-fetcher.example.com', 135 | [ 136 | 'favicon_url' => 'https://example.com/favicon.ico', 137 | 'icon_size' => null, 138 | 'icon_type' => Favicon::TYPE_ICON_UNKNOWN, 139 | ], 140 | Mockery::on(fn (CarbonInterface $ttl): bool => $ttl->eq($expectedTtl)), 141 | ]) 142 | ->once(); 143 | 144 | (new Favicon( 145 | 'https://example.com', 146 | 'https://example.com/favicon.ico', 147 | ))->cache(now()->addMinute(), true); 148 | } 149 | 150 | /** @test */ 151 | public function favicon_contents_be_stored(): void 152 | { 153 | Storage::fake(); 154 | 155 | Http::fake([ 156 | 'https://example.com/favicon.ico' => Http::response('favicon contents here'), 157 | '*' => Http::response('should not hit here'), 158 | ]); 159 | 160 | $favicon = new Favicon( 161 | url: 'https://example.com', 162 | faviconUrl: 'https://example.com/favicon.ico', 163 | ); 164 | 165 | $path = $favicon->store('favicons'); 166 | 167 | self::assertSame('favicon contents here', Storage::get($path)); 168 | } 169 | 170 | /** @test */ 171 | public function favicon_contents_be_stored_if_the_favicon_url_does_not_have_an_image_extension(): void 172 | { 173 | Storage::fake(); 174 | 175 | Http::fake([ 176 | 'https://example.com/favicon.ico' => Http::response('favicon contents here'), 177 | 'https://example.com/favicon.com' => Http::response(body: 'favicon contents here', headers: ['content-type' => 'image/png']), 178 | '*' => Http::response('should not hit here'), 179 | ]); 180 | 181 | $favicon = new Favicon( 182 | url: 'https://example.com', 183 | faviconUrl: 'https://example.com/favicon.com', 184 | ); 185 | 186 | $path = $favicon->store('favicons'); 187 | 188 | self::assertSame('favicon contents here', Storage::get($path)); 189 | self::assertTrue(Str::of($path)->endsWith('.png')); 190 | } 191 | 192 | /** @test */ 193 | public function favicon_contents_can_be_stored_with_a_custom_file_name(): void 194 | { 195 | Storage::fake(); 196 | 197 | Http::fake([ 198 | 'https://example.com/favicon.ico' => Http::response('favicon contents here'), 199 | '*' => Http::response('should not hit here'), 200 | ]); 201 | 202 | (new Favicon( 203 | url: 'https://example.com', 204 | faviconUrl: 'https://example.com/favicon.ico', 205 | ))->storeAs('favicons', 'fetched'); 206 | 207 | self::assertSame('favicon contents here', Storage::get('favicons/fetched.ico')); 208 | } 209 | 210 | public function icon_type_defaults_to_unknown_if_not_explicitly_set(): void 211 | { 212 | $iconType = (new Favicon( 213 | url: 'https://example.com', 214 | faviconUrl: 'https://example.com/favicon.ico', 215 | ))->getIconType(); 216 | 217 | self::assertSame(Favicon::TYPE_ICON_UNKNOWN, $iconType); 218 | } 219 | 220 | /** 221 | * @test 222 | * 223 | * @dataProvider iconTypeProvider 224 | */ 225 | public function icon_type_can_be_set_and_returned(string $expectedIconType): void 226 | { 227 | $iconType = (new Favicon( 228 | url: 'https://example.com', 229 | faviconUrl: 'https://example.com/favicon.ico', 230 | )) 231 | ->setIconType($expectedIconType) 232 | ->getIconType(); 233 | 234 | self::assertSame($expectedIconType, $iconType); 235 | } 236 | 237 | /** 238 | * @test 239 | * 240 | * @dataProvider iconSizeProvider 241 | */ 242 | public function icon_size_can_be_set_and_returned(?int $expectedIconSize): void 243 | { 244 | $iconSize = (new Favicon( 245 | url: 'https://example.com', 246 | faviconUrl: 'https://example.com/favicon.ico', 247 | )) 248 | ->setIconSize($expectedIconSize) 249 | ->getIconSize(); 250 | 251 | self::assertSame($expectedIconSize, $iconSize); 252 | } 253 | 254 | /** @test */ 255 | public function exception_is_thrown_when_trying_to_create_a_favicon_with_an_invalid_icon_type(): void 256 | { 257 | $this->expectException(InvalidIconTypeException::class); 258 | $this->expectExceptionMessage('The type [INVALID] is not a valid favicon type.'); 259 | 260 | (new Favicon( 261 | url: 'https://example.com', 262 | faviconUrl: 'https://example.com/favicon.ico', 263 | ))->setIconType('INVALID'); 264 | } 265 | 266 | /** @test */ 267 | public function exception_is_thrown_when_trying_to_create_a_favicon_with_an_invalid_icon_size(): void 268 | { 269 | $this->expectException(InvalidIconSizeException::class); 270 | $this->expectExceptionMessage('The size [-1] is not a valid favicon size.'); 271 | 272 | (new Favicon( 273 | url: 'https://example.com', 274 | faviconUrl: 'https://example.com/favicon.ico', 275 | ))->setIconSize(-1); 276 | } 277 | 278 | public static function iconTypeProvider(): array 279 | { 280 | return [ 281 | [Favicon::TYPE_ICON], 282 | [Favicon::TYPE_SHORTCUT_ICON], 283 | [Favicon::TYPE_APPLE_TOUCH_ICON], 284 | [Favicon::TYPE_ICON_UNKNOWN], 285 | ]; 286 | } 287 | 288 | public static function iconSizeProvider(): array 289 | { 290 | return [ 291 | [null], 292 | [16], 293 | [190], 294 | ]; 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /tests/Feature/FetcherManagerTest.php: -------------------------------------------------------------------------------- 1 | 'http']); 26 | 27 | self::assertInstanceOf(HttpDriver::class, FetcherManager::driver()); 28 | } 29 | 30 | /** @test */ 31 | public function http_driver_can_be_returned(): void 32 | { 33 | self::assertInstanceOf(HttpDriver::class, FetcherManager::driver('http')); 34 | } 35 | 36 | /** @test */ 37 | public function google_shared_stuff_driver_can_be_returned(): void 38 | { 39 | self::assertInstanceOf(GoogleSharedStuffDriver::class, FetcherManager::driver('google-shared-stuff')); 40 | } 41 | 42 | /** @test */ 43 | public function favicon_kit_driver_can_be_returned(): void 44 | { 45 | self::assertInstanceOf(FaviconKitDriver::class, FetcherManager::driver('favicon-kit')); 46 | } 47 | 48 | /** @test */ 49 | public function favicon_grabber_driver_can_be_returned(): void 50 | { 51 | self::assertInstanceOf(FaviconGrabberDriver::class, FetcherManager::driver('favicon-grabber')); 52 | } 53 | 54 | /** @test */ 55 | public function custom_driver_can_be_returned(): void 56 | { 57 | FetcherManager::extend('custom-driver', new CustomDriver()); 58 | self::assertInstanceOf(CustomDriver::class, FetcherManager::driver('custom-driver')); 59 | } 60 | 61 | /** @test */ 62 | public function exception_is_thrown_if_the_driver_is_invalid(): void 63 | { 64 | $this->expectException(FaviconFetcherException::class); 65 | $this->expectExceptionMessage('invalid is not a valid driver'); 66 | 67 | FetcherManager::driver('invalid'); 68 | } 69 | 70 | /** @test */ 71 | public function method_calls_to_the_manager_are_forwarded_to_the_driver(): void 72 | { 73 | $mock = tap( 74 | Mockery::mock(CustomDriver::class), 75 | function (Mockery\MockInterface $mock): void { 76 | $mock->shouldReceive('fetch') 77 | ->once() 78 | ->withArgs(['https://example.com']); 79 | } 80 | ); 81 | 82 | FetcherManager::extend('custom-driver', $mock); 83 | 84 | config(['favicon-fetcher.default' => 'custom-driver']); 85 | 86 | (new FetcherManager())->fetch('https://example.com'); 87 | } 88 | 89 | /** @test */ 90 | public function method_calls_to_the_manager_are_forwarded_to_the_driver_using_the_facade(): void 91 | { 92 | $mock = tap( 93 | Mockery::mock(CustomDriver::class), 94 | function (Mockery\MockInterface $mock): void { 95 | $mock->shouldReceive('fetch') 96 | ->once() 97 | ->withArgs(['https://example.com']); 98 | } 99 | ); 100 | 101 | FetcherManager::extend('custom-driver', $mock); 102 | 103 | config(['favicon-fetcher.default' => 'custom-driver']); 104 | 105 | Favicon::fetch('https://example.com'); 106 | } 107 | 108 | /** @test */ 109 | public function driver_can_be_returned_using_the_facade(): void 110 | { 111 | self::assertInstanceOf(FaviconKitDriver::class, Favicon::driver('favicon-kit')); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/Feature/TestCase.php: -------------------------------------------------------------------------------- 1 |