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

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 | `
`
38 |
39 |
40 |
41 | `
`
42 |
43 |
44 |
45 | `
`
46 |
47 |
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 |
--------------------------------------------------------------------------------