Download links favicons with Javascript

Introduction

Many websites including slions.net do decorate links with favicons from their target. To achieve this on XenForo I've been using that Favicon For Links AddOn. It is simply using a Google service to fetch said favicon. However I grew frustrated at the amount of prominent sites whose favicons are missing from this service including GitHub and decide to do something about.

Requirements

We not only wanted to reliably obtain favicons from websites we also wanted to make sure they displayed nicely on our dark themed pages by reverting colours of darkest icons.

Ideal solution

We hoped we could come up with a solution based on Ajax, unfortunately CORS security policies on modern browsers won't let you do that, unless both your website and the ones from which you are fetching favicons are setup properly. Therefore, we could implement that solution but failed to deploy it.
To disable security in Chrome launch it using the following command on Windows: chrome.exe --user-data-dir="C://Chrome-dev-session" --disable-web-security. Only then will you be able to load the following code sample:

HTML:
<!DOCTYPE html>
<html>
<body style="background-color:grey;">
<script type="text/javascript">
// This is the sha1 of the favicon returned by GitHub services when specified domain favicon is not available
const KDefaultFaviconHashGitHub = 'e5c76d46c49e27fef1f614a3459cc390f0a37f1c';
// This is the sha1 of the favicon returned by Google services when specified domain favicon is not available
const KDefaultFaviconHashGoogle = 'b82d0979d555bd137b33c15021129e06cbeea59a';
//
const KRequestFaviconGitHub = 'https://favicons.githubusercontent.com/';
const KRequestFaviconGoogle = 'https://www.google.com/s2/favicons?domain=';

document.addEventListener('DOMContentLoaded', function(event) {
    addFavicon("stackoverflow.com");
    addFavicon("bbc.co.uk");
    addFavicon("github.com");
    addFavicon("theregister.co.uk");
    addFavicon("slions.net");
    addFavicon("alternate.de");
    addFavicon("amazon.de");
    addFavicon("microsoft.com");
    addFavicon("apple.com");
    addFavicon("googlesource.com");
    addFavicon("android.googlesource.com");
    addFavicon("firebase.google.com");
    addFavicon("play.google.com");
    addFavicon("google.com");
    addFavicon("team-mediaportal.com");
    addFavicon("caseking.de");
    addFavicon("developer.mozilla.org");
    addFavicon("theguardian.com");
    addFavicon("niche-beauty.com");
    addFavicon("octobre-editions.com");
    addFavicon("dw.com");
    addFavicon("douglas.com");
    addFavicon("douglas.de");
    addFavicon("www.sncf.fr");
    addFavicon("paris.fr");
    addFavicon("bahn.de");
    addFavicon("hopfully.that.domain.does.not.exists.nowaythisisavaliddomain.fart");
});

/**
*
*/
function addFavicon(aDomain)
{
    var a = document.createElement("a");
    a.href = "http://" + aDomain;
    //a.style.display = "block";
    var img = document.createElement("img");
    var div = document.createElement("div");
    div.innerText = aDomain;
    div.style.verticalAlign = "middle";
    div.style.display = "inline-block";
    img.style.width = "16px";
    img.style.height = "16px";
    img.style.verticalAlign = "middle";
    img.style.display = "inline-block";
    img.style.marginRight = "4px";
    a.appendChild(img);
    a.appendChild(div);
    document.body.appendChild(a);
    document.body.appendChild(document.createElement("p"));

    downloadFavicon(aDomain,img,a);

}

/**
* Set downloaded favicon as source to the given image.
*/
function downloadFavicon(aDomain,aImage)
{
    var img = document.createElement("img");
    img.onload = function () {
        // Invert image if it is too dark, usefull for dark web sites
        isImageDark(img.src,function (isDark)
        {
            if (isDark)
            {
                aImage.style.filter = "invert(1)";
            }
            aImage.src = img.src;
        });
    }

    // First try GitHub API
    downloadBlob(KRequestFaviconGitHub+aDomain,  async function response(e)
    {
        var urlCreator = window.URL || window.webkitURL;
        const gitHubResponse = this.response;
        computeHash(gitHubResponse, function (aHash) {
            //console.log(hash);
            //console.log(KDefaultFaviconHashGitHub);
            if (aHash!==KDefaultFaviconHashGitHub)
            {
                // We got a valid favicon, use it then
                img.src = urlCreator.createObjectURL(gitHubResponse);
                return;
            }

            // No valid favicon found using GitHub API, fallback to Goolge API
            downloadBlob(KRequestFaviconGoogle+aDomain,  async function response(e)
            {
                const googleResponse = this.response;
                computeHash(googleResponse, function (aHash) {
                    if (aHash!==KDefaultFaviconHashGoogle)
                    {
                        // We got a valid favicon, use it then
                        img.src = urlCreator.createObjectURL(googleResponse);
                        return;
                    }
          
                    // Neither GitHub nor Google have a favicon
                    // Try it all over again without subdomain maybe, that notably tackles the case of android.googlesource.com
                    var matches = aDomain.match(/^(.+?)\.(.+\..+)/i);
                    //console.log(matches);
                    var domain = matches && matches[2];  // domain will be null if no match is found
                    if (domain==null)
                    {
                        // Still no valid favicon, use it anyway
                        img.src = urlCreator.createObjectURL(googleResponse);
                        return;
                    }
          
                    // Have another go at it after poping latest subdomain
                    downloadFavicon(domain,aImage);         
                });
            });
        });
    });
}

/**
* Download given URL as blob.
*/
function downloadBlob(aUrl,aOnload)
{
    var xhr = new XMLHttpRequest();
    xhr.open("GET", aUrl);
    xhr.responseType = "blob";
    xhr.onload = aOnload;
    xhr.send();
}

/**
* Compute hash for given string and return it as hex string.
*/
function computeHash(blob,aCallBack) {
    var a = new FileReader();
    a.readAsArrayBuffer(blob);
    a.onloadend = async function () {
    const hashBuffer = await crypto.subtle.digest("SHA-1", a.result);
    const hashArray = Array.from(new Uint8Array(hashBuffer));                     // convert buffer to byte array
    const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string
    //document.getElementById("h1id").innerHTML = hashHex;
    aCallBack(hashHex);
    };
}

/**
* See: https://stackoverflow.com/a/13766539/3969362
*/
function isImageDark(imageSrc,callback) {
        // Adjust this between 0 and 1 where 0 is always dark and 1 is never dark
        // 0.3 was flagging stackoverflow.com and team-mediaportal as dark 0.4 was is not
        // 0.8 is flagging bbc.co.uk as dark and 0.9 is not
        // 0.4 is flagging developer.mozilla.org as dark 0.5 was is not
        var fuzzy = 0.9;
        var img = document.createElement("img");
        img.src = imageSrc;
        img.style.display = "none";
        document.body.appendChild(img);

        img.onload = function() {
            // create canvas
            var canvas = document.createElement("canvas");
            canvas.width = this.width;
            canvas.height = this.height;

            var ctx = canvas.getContext("2d");
            ctx.drawImage(this,0,0);

            var imageData = ctx.getImageData(0,0,canvas.width,canvas.height);
            var data = imageData.data;
            var r,g,b, max_rgb;
            var light = 0, dark = 0;

            for(var x = 0, len = data.length; x < len; x+=4) {
                r = data[x];
                g = data[x+1];
                b = data[x+2];

                max_rgb = Math.max(Math.max(r, g), b);
                if (max_rgb < 128)
                    dark++;
                else
                    light++;
            }

            var dl_diff = ((light - dark) / (this.width*this.height));
            if (dl_diff + fuzzy < 0)
                callback(true); /* Dark. */
            else
                callback(false);  /* Not dark. */
        }
    }

</script>

</body>
</html>

That solution would have been great as it does not require any manual configuration here is how it works:
  1. Check if favicon is available on GitHub.
  2. Check if favicon is available on Google.
  3. If favicon is still not available pop subdomain if any and try again, otherwise just give up and use default favicon from Google.
  4. Check if selected favicon is too dark and revert its colours if needed.
We also tried a similar logic using img elements to download icons instead of XMLHttpRequest but that would not bypass CORS security policies as canvas element created from cross origin img are marked as tainted and do not let you access image data.

Fall-back solution

Seeing as we could not deploy our favourite solution he had to come up with something that worked and still fitted our requirements. We now rely on a predefined list of hostnames to point us to the favicon download link and tell us if it needs to be inverted. Unknown hostnames are just using Google services.

HTML:
<!DOCTYPE html>
<html>
<body style="background-color:grey;">
<script type="text/javascript">

const KRequestFaviconGitHub = 'https://favicons.githubusercontent.com/';
const KRequestFaviconGoogle = 'https://www.google.com/s2/favicons?domain=';

const KDefaultUrl = KRequestFaviconGoogle;

// We rely on pre-defined hostname configurations
const hostnames = {
    "stackoverflow.com": { url:KRequestFaviconGoogle+"stackoverflow.com", invert:0 },
    "theregister.co.uk": { url:KRequestFaviconGoogle+"theregister.co.uk", invert:1 },
    "github.com": { url:KRequestFaviconGitHub+"github.com", invert:1 },
    "android.googlesource.com": { url:KRequestFaviconGoogle+"googlesource.com", invert:0 },
    "developer.android.com": { url:KRequestFaviconGitHub+"developer.android.com", invert:0 }
};

document.addEventListener('DOMContentLoaded', function(event) {

    addFavicon("stackoverflow.com");
    addFavicon("bbc.co.uk");
    addFavicon("github.com");
    addFavicon("theregister.co.uk");
    addFavicon("developer.android.com");
    addFavicon("android-doc.github.io");
    addFavicon("slions.net");
    addFavicon("alternate.de");
    addFavicon("amazon.de");
    addFavicon("microsoft.com");
    addFavicon("apple.com");
    addFavicon("googlesource.com");
    addFavicon("android.googlesource.com");
    addFavicon("firebase.google.com");
    addFavicon("play.google.com");
    addFavicon("google.com");
    addFavicon("team-mediaportal.com");
    addFavicon("caseking.de");
    addFavicon("developer.mozilla.org");
    addFavicon("theguardian.com");
    addFavicon("niche-beauty.com");
    addFavicon("octobre-editions.com");
    addFavicon("dw.com");
    addFavicon("douglas.com");
    addFavicon("douglas.de");
    addFavicon("www.sncf.fr");
    addFavicon("paris.fr");
    addFavicon("bahn.de");
    addFavicon("hopfully.that.domain.does.not.exists.nowaythisisavaliddomain.fart");

});

/**
*
*/
function addFavicon(aDomain)
{
    var a = document.createElement("a");
    a.href = "http://" + aDomain;
    //a.style.display = "block";
    var div = document.createElement("div");
    div.innerText = aDomain;
    div.style.verticalAlign = "middle";
    div.style.display = "inline-block";
    var img = document.createElement("img");
    img.className = "link-favicon";
    img.style.width = "16px";
    img.style.height = "16px";
    img.style.verticalAlign = "middle";
    img.style.display = "inline-block";
    img.style.marginRight = "4px";
    a.prepend(img);
    a.appendChild(div);
    document.body.appendChild(a);
    document.body.appendChild(document.createElement("p"));

    const conf = hostnames[aDomain]
    if (conf==null)
    {
        img.src = KDefaultUrl+aDomain;
    }
    else
    {
        img.src = conf.url;
        img.style.filter = "invert(" + conf.invert + ")";
    }
}
</script>
</body>
</html>

Conclusion

We implemented our fall-back solution in a modified version of that XenForo AddOn.

Resources

 
Last edited:
Top