├── .gitignore ├── test ├── fallback.svg └── test.js ├── vercel.json ├── README.md ├── package.json ├── api └── avatar.js └── avatar-html.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .vercel 4 | .cache 5 | -------------------------------------------------------------------------------- /test/fallback.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "api/avatar.js": { 4 | "includeFiles": "" 5 | } 6 | }, 7 | "rewrites": [ 8 | { "source": "/", "destination": "/api/avatar/" }, 9 | { "source": "/:path*", "destination": "/api/avatar/" }, 10 | { "source": "/:path/", "destination": "/api/avatar/" } 11 | ], 12 | "trailingSlash": true 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

11ty Logo

2 | 3 | # IndieWeb Avatar API 4 | 5 | A runtime service to extract avatar images from: 6 | 7 | 1. `` 8 | 1. `` 9 | 1. `` 10 | 1. `favicon.ico` (added September 20, 2021) 11 | 1. `favicon.ico` that isn’t an `.ico` file (added December 1, 2023) 12 | 1. First `` in `
` (added December 1, 2023) 13 | 1. TODO: Support Data URIs in attribute values. (e.g. https://joshcrain.io) 14 | 1. TODO: `` 15 | 1. TODO (maybe): `` 16 | 1. TODO (maybe): `` 17 | 18 | All `rel` lookups match against attribute values that are space separated lists. 19 | 20 | Update July 22, 2024: Supports `svg` favicons but converts to `png` for performance reasons (some folks had _huge_ SVG favicons). 21 | 22 | ## Usage 23 | 24 | URLs have the formats: 25 | 26 | ``` 27 | /:url/ 28 | ``` 29 | 30 | * `url` must be URI encoded. 31 | 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eleventy-api-indieweb-avatar", 3 | "version": "1.0.0", 4 | "description": "A runtime service to extract avatar images from the HTML on a web site.", 5 | "type": "module", 6 | "main": "avatar-html.js", 7 | "engines": { 8 | "node": ">=18" 9 | }, 10 | "scripts": { 11 | "test": "node test/test.js", 12 | "start": "npx vercel dev" 13 | }, 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/11ty/indieweb-avatar.git" 18 | }, 19 | "author": { 20 | "name": "Zach Leatherman", 21 | "email": "zachleatherman@gmail.com", 22 | "url": "https://zachleat.com/" 23 | }, 24 | "keywords": [], 25 | "bugs": { 26 | "url": "https://github.com/11ty/indieweb-avatar/issues" 27 | }, 28 | "homepage": "https://github.com/11ty/indieweb-avatar#readme", 29 | "dependencies": { 30 | "@11ty/eleventy-fetch": "^5.0.2", 31 | "@11ty/eleventy-img": "^6.0.1", 32 | "cheerio": "^1.0.0", 33 | "ico-to-png": "^0.2.2" 34 | }, 35 | "devDependencies": { 36 | "vercel": "^41.2.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import AvatarHtml from "../avatar-html.js"; 2 | // process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = 0; 3 | 4 | (async function() { 5 | // let url = "https://fixa11y.com/"; // svg 6 | // let url = "https://changelog.com/"; // favicon 7 | // let url = "https://www.youtube.com/watch?v=eRRkvI-w5Ik"; 8 | // let url = "https://lynnandtonic.com"; 9 | // let url = "https://sarah.dev/"; 10 | // let url = "https://changelog.com/jsparty/217"; 11 | // let url = "https://stateofjs.com/en-us/"; 12 | // let url = "https://twitter.com/"; 13 | // let url = "https://opencollective.com/"; 14 | // let url = "https://www.linkedin.com"; 15 | // let url = "https://www.zachleat.com/twitter/"; 16 | // let url = "https://codepen.io"; 17 | // let url = "https://discord.com/"; 18 | // let url = "https://www.stanford.edu"; // bad cert error 19 | let url = "https://www.noaa.gov/"; // bad cert error 20 | let avatar = new AvatarHtml(url); 21 | let html = await avatar.fetch(); 22 | 23 | try { 24 | let stats = await avatar.getAvatar(150, "png"); 25 | console.log( stats ); 26 | } catch(e) { 27 | console.log( "ERROR", e ); 28 | } 29 | 30 | // console.log( stats[format][0].buffer.toString() ); 31 | })(); -------------------------------------------------------------------------------- /api/avatar.js: -------------------------------------------------------------------------------- 1 | import AvatarHtml from "../avatar-html.js"; 2 | 3 | const ONE_HOUR = 60*60; 4 | const ONE_DAY = ONE_HOUR*24; 5 | const ONE_WEEK = ONE_DAY*7; 6 | 7 | const IMAGE_WIDTH = 60; 8 | const IMAGE_HEIGHT = 60; 9 | const FALLBACK_IMAGE_FORMAT = "png"; 10 | 11 | function isFullUrl(url) { 12 | try { 13 | new URL(url); 14 | return true; 15 | } catch(e) { 16 | // invalid url OR local path 17 | return false; 18 | } 19 | } 20 | 21 | function getEmptyImageResponse(errorMessage) { 22 | // We need to return 200 here or Firefox won’t display the image 23 | // empty svg 24 | return new Response(``, { 25 | status: 200, 26 | headers: { 27 | "content-type": "image/svg+xml", 28 | "x-11ty-error-message": errorMessage, 29 | "cache-control": `public, s-maxage=${ONE_WEEK}, stale-while-revalidate=${ONE_DAY}`, 30 | } 31 | }) 32 | } 33 | 34 | export async function GET(request, context) { 35 | // e.g. /https%3A%2F%2Fwww.11ty.dev%2F/ 36 | let requestUrl = new URL(request.url); 37 | let [url] = requestUrl.pathname.split("/").filter(entry => !!entry); 38 | 39 | if(url?.endsWith("favicon.ico")) { 40 | return getEmptyImageResponse(""); 41 | } 42 | 43 | url = decodeURIComponent(url); 44 | 45 | try { 46 | // output to Function logs 47 | console.log("Fetching", url); 48 | 49 | // short circuit circular requests 50 | if(isFullUrl(url) && (new URL(url)).hostname.endsWith(".indieweb-avatar.11ty.dev")) { 51 | return getEmptyImageResponse("Circular request"); 52 | } 53 | 54 | let avatar = new AvatarHtml(url); 55 | await avatar.fetch(); 56 | 57 | let stats = await avatar.getAvatar(IMAGE_WIDTH, FALLBACK_IMAGE_FORMAT); 58 | let format = Object.keys(stats).pop(); 59 | let stat = stats[format][0]; 60 | 61 | return new Response(stat.buffer, { 62 | status: 200, 63 | headers: { 64 | "content-type": stat.sourceType, 65 | "cache-control": `public, max-age=${ONE_HOUR}, s-maxage=${ONE_WEEK}, stale-while-revalidate=${ONE_DAY}` 66 | } 67 | }); 68 | } catch (error) { 69 | console.log("Error", error); 70 | return getEmptyImageResponse(error.message); 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /avatar-html.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | import EleventyImage from "@11ty/eleventy-img"; 3 | import EleventyFetch from "@11ty/eleventy-fetch"; 4 | import icoToPng from "ico-to-png"; 5 | 6 | const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"; 7 | 8 | class AvatarHtml { 9 | constructor(url) { 10 | this.url = url; 11 | 12 | if(!this.isFullUrl(url)) { 13 | throw new Error(`Invalid \`url\`: ${url}`); 14 | } 15 | } 16 | 17 | isFullUrl(url) { 18 | try { 19 | new URL(url); 20 | return true; 21 | } catch(e) { 22 | // invalid url OR local path 23 | return false; 24 | } 25 | } 26 | 27 | async fetch() { 28 | let response = await fetch(this.url, { 29 | headers: { 30 | "user-agent": USER_AGENT 31 | } 32 | }); 33 | let body = await response.text(); 34 | this.body = body; 35 | 36 | this.$ = cheerio.load(body); 37 | return body; 38 | } 39 | 40 | normalizePath(path) { 41 | let u = new URL(path, this.url); 42 | return u.href; 43 | } 44 | 45 | /* Returns largest found */ 46 | findRelIcons() { 47 | let results = []; 48 | 49 | let icons = this.$("link[rel~='icon']"); 50 | 51 | for(let icon of icons) { 52 | let sizesStr = icon.attribs.sizes; 53 | let typeStr = icon.attribs.type; 54 | let type; 55 | if(typeStr) { 56 | if(typeStr.startsWith("image/") || typeStr.startsWith("img/")) { 57 | type = typeStr.split("/")[1]; 58 | } 59 | } 60 | 61 | results.push({ 62 | href: this.normalizePath(icon.attribs.href), 63 | size: sizesStr ? sizesStr.split("x") : [0, 0], 64 | type, 65 | }); 66 | } 67 | 68 | // TODO deprioritize "image/x-icon" if the sizes are the same 69 | return results.sort((a, b) => { 70 | let ordering = b.size[0] - a.size[0]; 71 | if(!Number.isNaN(ordering)) { return ordering; } 72 | else if(b.size[0].toLowerCase() === 'any') { return 1; } 73 | else { return -1; } 74 | }); 75 | } 76 | 77 | findAppleTouchIcon() { 78 | let icon = this.$("link[rel~='apple-touch-icon']"); 79 | if(icon.length > 0) { 80 | let hrefs = []; 81 | for(let i of icon) { 82 | let size = parseInt(i.attribs.sizes) || 0; // NUMxNUM parses to NUM 83 | hrefs.push({ href: i.attribs.href, size }); 84 | } 85 | hrefs.sort((a, b) => { 86 | if(a.size && b.size) { 87 | return b.size - a.size; 88 | } 89 | if(a.size) { 90 | return -1; 91 | } 92 | if(b.size) { 93 | return 1; 94 | } 95 | return 0; 96 | }); 97 | 98 | return this.normalizePath(hrefs[0].href); 99 | } 100 | 101 | let precomposedIcon = this.$("link[rel~='apple-touch-icon-precomposed']"); 102 | if(precomposedIcon.length > 0) { 103 | return this.normalizePath(precomposedIcon[0].attribs.href); 104 | } 105 | } 106 | 107 | async convertIcoToPng(href, width) { 108 | let icoBuffer = await EleventyFetch(href, { 109 | type: "buffer", 110 | dryRun: true, 111 | fetchOptions: { 112 | headers: { 113 | "user-agent": USER_AGENT 114 | } 115 | } 116 | }); 117 | return icoToPng(icoBuffer, width); 118 | } 119 | 120 | async getAvatar(width, fallbackImageFormat) { 121 | let appleTouchIconHref = this.findAppleTouchIcon(); 122 | if(appleTouchIconHref) { 123 | let input = appleTouchIconHref; 124 | // discord.com uses an .ico file in its apple touch icon 125 | if(appleTouchIconHref.endsWith(".ico")) { 126 | input = await this.convertIcoToPng(appleTouchIconHref, width); 127 | } 128 | return this.optimizeAvatar(input, width, fallbackImageFormat); 129 | } 130 | 131 | let relIcons = this.findRelIcons(); 132 | let fallbackIconHref; 133 | 134 | if(relIcons.length) { 135 | // https://stateofjs.com/en-us/ has a bad mime `type` for their SVG icon 136 | if(relIcons[0].type === "x-icon" && !(relIcons[0].href && relIcons[0].href.endsWith(".ico"))) { 137 | let format = fallbackImageFormat; 138 | return this.optimizeAvatar(relIcons[0].href, width, format); 139 | } else if(relIcons[0].type === "x-icon" || relIcons[0].href && relIcons[0].href.endsWith(".ico")) { 140 | let pngBuffer = await this.convertIcoToPng(relIcons[0].href, width); 141 | return this.optimizeAvatar(pngBuffer, width, "png"); 142 | } else if(!relIcons[0].type) { 143 | fallbackIconHref = relIcons[0].href; 144 | } else { 145 | let format = relIcons[0].type || fallbackImageFormat; 146 | return this.optimizeAvatar(relIcons[0].href, width, format) 147 | } 148 | } 149 | let href = fallbackIconHref || this.normalizePath("/favicon.ico"); 150 | 151 | try { 152 | let pngBuffer = await this.convertIcoToPng(href, width); 153 | return await this.optimizeAvatar(pngBuffer, width, fallbackImageFormat); 154 | } catch(e) { 155 | try { 156 | // not all favicon.ico are ico files 157 | return await this.optimizeAvatar(href, width, fallbackImageFormat); 158 | } catch(e) { 159 | // if favicon.ico didn’t work, use the first header image 160 | let headerImage = this.$("header img"); 161 | if(headerImage.length > 0) { 162 | let firstHeaderImageSrc = this.normalizePath(headerImage[0].attribs.src); 163 | return this.optimizeAvatar(firstHeaderImageSrc, width, fallbackImageFormat); 164 | } 165 | 166 | throw e; 167 | } 168 | } 169 | } 170 | 171 | async optimizeAvatar(sharpInput, width, imageFormat) { 172 | // normalize format 173 | if(imageFormat && (imageFormat === "svg+xml" || imageFormat === "svg")) { 174 | imageFormat = "png"; 175 | } 176 | return EleventyImage(sharpInput, { 177 | widths: [width], 178 | formats: [imageFormat], 179 | dryRun: true, 180 | failOnError: true, 181 | }); 182 | } 183 | } 184 | 185 | export default AvatarHtml; 186 | --------------------------------------------------------------------------------