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

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 |
--------------------------------------------------------------------------------