├── .gitignore ├── vercel.json ├── package.json ├── README.md ├── ogImageHtml.js └── api └── og.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .vercel 4 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "api/og.js": { 4 | "includeFiles": "" 5 | } 6 | }, 7 | "rewrites": [ 8 | { "source": "/", "destination": "/api/og/" }, 9 | { "source": "/:url*", "destination": "/api/og/" }, 10 | { "source": "/:url/", "destination": "/api/og/" }, 11 | { "source": "/:url/:size/", "destination": "/api/og/" }, 12 | { "source": "/:url/:size/:format/", "destination": "/api/og/" }, 13 | { "source": "/:url/:size/:format/:onerror/", "destination": "/api/og/" }, 14 | { "source": "/:url/:size/:format/:onerror/:cachebust/", "destination": "/api/og/" } 15 | ], 16 | "trailingSlash": true 17 | } 18 | -------------------------------------------------------------------------------- /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 | "scripts": { 6 | "start": "npx vercel dev" 7 | }, 8 | "type": "module", 9 | "main": "./ogImageHtml.js", 10 | "license": "MIT", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/11ty/indieweb-avatar.git" 14 | }, 15 | "author": { 16 | "name": "Zach Leatherman", 17 | "email": "zachleatherman@gmail.com", 18 | "url": "https://zachleat.com/" 19 | }, 20 | "keywords": [], 21 | "bugs": { 22 | "url": "https://github.com/11ty/indieweb-avatar/issues" 23 | }, 24 | "homepage": "https://github.com/11ty/indieweb-avatar#readme", 25 | "dependencies": { 26 | "@11ty/eleventy-img": "^6.0.1", 27 | "cheerio": "^1.0.0" 28 | }, 29 | "devDependencies": { 30 | "vercel": "^41.2.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

11ty Logo

2 | 3 | # Open Graph Image API 4 | 5 | A runtime service to return optimized Open Graph images from a URL. Works with: 6 | 7 | 1. `` 8 | 1. `` 9 | 1. `` 10 | 1. `` 11 | 12 | ## Usage 13 | 14 | URLs have the formats: 15 | 16 | ``` 17 | /:url/ 18 | /:url/:size/ 19 | /:url/:size/:format/ 20 | /:url/:size/:format/onerror/ 21 | ``` 22 | 23 | * `url` must be URI encoded. 24 | * `size` (optional) can be `small` (375×_), `medium` (650×_), or `auto` (keep original width) 25 | * `format` must by an output image format supported by [Eleventy Image](https://www.11ty.dev/docs/plugins/image/) (`auto` is supported) 26 | 27 | ``` 28 | /:url/onerror/ 29 | /:url/:size/onerror/ 30 | /:url/:size/:format/onerror/ 31 | ``` 32 | 33 | * Appending the string value `onerror` to any valid URL format will return empty content (no default image) if an opengraph image is not found at the target URL. This will trigger `` in the browser which you can handle on the client (e.g. `` to remove the image). 34 | 35 | ## Demos 36 | 37 | `OpenGraph Image for netlify.com` 38 | 39 | OpenGraph Image for netlify.com 40 | 41 | `OpenGraph Image for 11ty.dev` 42 | 43 | OpenGraph Image for 11ty.dev 44 | 45 | `OpenGraph Image for zachleat.com` 46 | 47 | OpenGraph Image for zachleat.com 48 | 49 | ### Advanced: Manual Cache Busting 50 | 51 | If the images aren’t updating at a high enough frequency you can pass in your own cache busting key using an underscore prefix `_` after your URL. 52 | 53 | This can be any arbitrary string tied to your unique build, here’s an example that uses today’s date. 54 | 55 | ``` 56 | /:url/_20210802/ 57 | /:url/:size/_20210802/ 58 | /:url/:size/:format/_20210802/ 59 | ``` 60 | -------------------------------------------------------------------------------- /ogImageHtml.js: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | import EleventyImage from "@11ty/eleventy-img"; 3 | 4 | const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15"; 5 | 6 | class OgImageHtml { 7 | constructor(url) { 8 | this.url = url; 9 | 10 | if(!this.isFullUrl(url)) { 11 | throw new Error(`Invalid \`url\`: ${url}`); 12 | } 13 | } 14 | 15 | isFullUrl(url) { 16 | try { 17 | new URL(url); 18 | return true; 19 | } catch(e) { 20 | // invalid url OR local path 21 | return false; 22 | } 23 | } 24 | 25 | async fetch() { 26 | let response = await fetch(this.url, { 27 | headers: { 28 | "User-Agent": USER_AGENT, 29 | } 30 | }); 31 | let body = await response.text(); 32 | this.body = body; 33 | 34 | this.$ = cheerio.load(body); 35 | 36 | return body; 37 | } 38 | 39 | normalizePath(path) { 40 | let u = new URL(path, this.url); 41 | return u.href; 42 | } 43 | 44 | findImageUrls() { 45 | let results = new Set(); 46 | 47 | let cases = [ 48 | ["meta[name='og:image:secure_url']", "content"], 49 | ["meta[name='og:image']", "content"], 50 | ["meta[property='og:image']", "content"], // not sure if this is standardized 51 | ["meta[name='twitter:image']", "content"], 52 | 53 | // YouTube specific: https://github.com/11ty/api-opengraph-image/issues/6 54 | ["link[rel='image_src']", "href"], 55 | ["link[itemprop='thumbnailUrl']", "href"], 56 | ]; 57 | 58 | for(let [selector, attribute] of cases) { 59 | let imageUrl = this.$(selector).attr(attribute); 60 | if(imageUrl) { 61 | results.add(imageUrl); 62 | continue; 63 | } 64 | } 65 | 66 | // More YouTube specific stuff: https://github.com/11ty/api-opengraph-image/issues/6 67 | let u = new URL(this.url); 68 | if(u.host.endsWith(".youtube.com") || u.host === "youtube.com") { 69 | // Sizes borrowed from https://paulirish.github.io/lite-youtube-embed/testpage/poster-image-availability.html 70 | // let sizes = ["maxresdefault", "sddefault", "hqdefault", "mqdefault", "default"]; 71 | let videoId = u.searchParams.get("v"); 72 | if(videoId) { 73 | results.add(`https://i.ytimg.com/vi_webp/${videoId}/maxresdefault.webp`); 74 | results.add(`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`); 75 | // results.add(`https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`); 76 | } 77 | } 78 | // TODO youtu.be style https://github.com/11ty/api-opengraph-image/issues/8 79 | 80 | console.log( "Found urls:", Array.from(results) ); 81 | 82 | return Array.from(results); 83 | } 84 | 85 | async getImages() { 86 | return this.findImageUrls().map(url => { 87 | return ""+ (new URL(url, this.url)); 88 | }); 89 | } 90 | 91 | async optimizeImage(imageUrl, imageFormat, maxWidth) { 92 | let stats = await EleventyImage(imageUrl, { 93 | widths: [maxWidth || "auto"], 94 | formats: [imageFormat], 95 | dryRun: true, 96 | useCache: false, 97 | cacheOptions: { 98 | headers: { 99 | "User-Agent": USER_AGENT, 100 | } 101 | } 102 | }); 103 | 104 | return stats; 105 | } 106 | } 107 | 108 | export default OgImageHtml; 109 | -------------------------------------------------------------------------------- /api/og.js: -------------------------------------------------------------------------------- 1 | import OgImageHtml from "../ogImageHtml.js"; 2 | 3 | const IMAGE_WIDTH = 1200; 4 | const IMAGE_HEIGHT = 630; 5 | const FALLBACK_IMAGE_FORMAT = "png"; 6 | const ERROR_URL_SEGMENT = "onerror"; 7 | 8 | const ONE_MINUTE = 60; 9 | const ONE_DAY = ONE_MINUTE*60*24; 10 | const ONE_WEEK = ONE_DAY*7; 11 | 12 | function isFullUrl(url) { 13 | try { 14 | new URL(url); 15 | return true; 16 | } catch(e) { 17 | // invalid url OR local path 18 | return false; 19 | } 20 | } 21 | 22 | function getEmptyImage() { 23 | return new Response(``, { 24 | // We need to return 200 here or Firefox won’t display the image 25 | // HOWEVER a 200 means that if it times out on the first attempt it will stay the default image until the next build. 26 | status: 200, 27 | headers: { 28 | "content-type": "image/svg+xml", 29 | "cache-control": `public, s-maxage=${ONE_WEEK}, stale-while-revalidate=${ONE_DAY}` 30 | } 31 | }); 32 | } 33 | 34 | // Eleventy logo 35 | function getLogoImage(message, ttl) { 36 | return new Response(``, { 37 | // We need to return 200 here or Firefox won’t display the image 38 | // HOWEVER a 200 means that if it times out on the first attempt it will stay the default image until the next build. 39 | status: 200, 40 | headers: { 41 | "content-type": "image/svg+xml", 42 | "x-11ty-error-message": message, 43 | "cache-control": `public, s-maxage=${ttl}, stale-while-revalidate=${ONE_DAY}` 44 | } 45 | }); 46 | } 47 | 48 | function getErrorImage(message, ttl) { 49 | // Use case: we want to remove the `` clientside when an OG image is not found. 50 | // So to trigger `` on a 200 or 404 we *cannot* return valid image content 51 | 52 | return new Response("", { 53 | // We need to return 200 here or Firefox won’t display the image 54 | // HOWEVER a 200 means that if it times out on the first attempt it will stay the default image until the next build. 55 | // Will try again after TTL 56 | status: 200, 57 | headers: { 58 | "content-type": "application/json", 59 | "x-11ty-error-message": message, 60 | "cache-control": `public, s-maxage=${ttl}, stale-while-revalidate=${ONE_DAY}` 61 | } 62 | }); 63 | } 64 | 65 | export async function GET(request, context) { 66 | // /:url/:size/:format/ 67 | // e.g. /https%3A%2F%2Fwww.11ty.dev%2F/ 68 | let requestUrl = new URL(request.url); 69 | let [url, size, imageFormat, returnEmptyImage, cacheBuster] = requestUrl.pathname.split("/").filter(entry => !!entry); 70 | 71 | if(request.url?.endsWith("favicon.ico")) { 72 | return getEmptyImage(); 73 | } 74 | 75 | url = decodeURIComponent(url); 76 | 77 | // Whether or to return empty image content 78 | let returnEmptyImageWhenNotFound = false; 79 | 80 | // Manage your own frequency by using a _ prefix and then a hash buster string after your URL 81 | // e.g. /https%3A%2F%2Fwww.11ty.dev%2F/_20210802/ and set this to today’s date when you deploy 82 | if(size) { 83 | if(size.startsWith("_")) { 84 | cacheBuster = size; 85 | size = undefined; 86 | } else if(size === ERROR_URL_SEGMENT) { 87 | returnEmptyImageWhenNotFound = true; 88 | size = undefined; 89 | } 90 | } 91 | 92 | if(imageFormat) { 93 | if(imageFormat.startsWith("_")) { 94 | cacheBuster = imageFormat; 95 | imageFormat = undefined; 96 | } else if(imageFormat === ERROR_URL_SEGMENT) { 97 | returnEmptyImageWhenNotFound = true; 98 | imageFormat = undefined; 99 | } 100 | } 101 | 102 | if(returnEmptyImage) { 103 | if(returnEmptyImage.startsWith("_")) { 104 | cacheBuster = returnEmptyImage; 105 | } else if(returnEmptyImage === ERROR_URL_SEGMENT) { 106 | returnEmptyImageWhenNotFound = true; 107 | } 108 | } 109 | 110 | try { 111 | // output to Function logs 112 | let maxWidth = IMAGE_WIDTH; 113 | if(size === "small") { 114 | maxWidth = 375; 115 | } else if(size === "medium") { 116 | maxWidth = 650; 117 | } 118 | 119 | console.log( "Request", {url, size, imageFormat, cacheBuster} ); 120 | 121 | // short circuit circular requests 122 | if(isFullUrl(url) && (new URL(url)).hostname.endsWith(".opengraph.11ty.dev")) { 123 | return getEmptyImage(); 124 | } 125 | 126 | let og = new OgImageHtml(url); 127 | await og.fetch(); 128 | 129 | let imageUrls = await og.getImages(); 130 | if(!imageUrls.length) { 131 | if(returnEmptyImageWhenNotFound) { 132 | return getErrorImage(`No Open Graph images found for ${url}`, ONE_DAY); 133 | } 134 | 135 | return getLogoImage(`No Open Graph images found for ${url}`, ONE_DAY); 136 | } 137 | 138 | // TODO: when requests to https://v1.screenshot.11ty.dev/ show an error (the default SVG image) 139 | // this service should error with _that_ image and the error message headers. 140 | 141 | let settled = await Promise.allSettled(imageUrls.map(url => { 142 | return og.optimizeImage(url, imageFormat || FALLBACK_IMAGE_FORMAT, maxWidth); 143 | })); 144 | 145 | let promises = settled.filter(p => { 146 | if(p.status === "fulfilled" && p.value) { 147 | return Object.keys(p.value).length > 0; 148 | } 149 | return false; 150 | }).map(p => { 151 | let format = Object.keys(p.value).pop(); 152 | return p.value[format][0]; 153 | }).sort((a, b) => { 154 | // descending 155 | return b.width - a.width; 156 | }); 157 | 158 | if(promises.length === 0) { 159 | throw new Error("No image found."); 160 | } 161 | 162 | let stat = promises[0]; 163 | console.log( "Found match", url, { format: stat.format, width: stat.width, height: stat.height, size: stat.size } ); 164 | 165 | return new Response(stat.buffer, { 166 | code: 200, 167 | headers: { 168 | "content-type": stat.sourceType, 169 | "x-cache-buster": cacheBuster, 170 | "cache-control": `public, s-maxage=${ONE_WEEK}, stale-while-revalidate=${ONE_DAY}` 171 | } 172 | }); 173 | } catch (error) { 174 | console.log("Error", error); 175 | return getLogoImage(error.message, ONE_MINUTE * 5); 176 | } 177 | } 178 | 179 | --------------------------------------------------------------------------------