/
RecommendationMetadataService.ts
125 lines (112 loc) · 4.45 KB
/
RecommendationMetadataService.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
type OembedMetadata<Type extends string> = {
version: '1.0',
type: Type,
url: string,
metadata: {
title: string|null,
description: string|null,
publisher: string|null,
author: string|null,
thumbnail: string|null,
icon: string|null
},
body?: Type extends 'mention' ? string : unknown,
contentType?: Type extends 'mention' ? string : unknown
}
type OEmbedService = {
fetchOembedDataFromUrl<Type extends string>(url: string, type: Type, options?: {timeout?: number}): Promise<OembedMetadata<Type>>
}
type ExternalRequest = {
get(url: string, options: object): Promise<{statusCode: number, body: string}>
}
export type RecommendationMetadata = {
title: string|null,
excerpt: string|null,
featuredImage: URL|null,
favicon: URL|null,
oneClickSubscribe: boolean
}
export class RecommendationMetadataService {
#oembedService: OEmbedService;
#externalRequest: ExternalRequest;
constructor(dependencies: {oembedService: OEmbedService, externalRequest: ExternalRequest}) {
this.#oembedService = dependencies.oembedService;
this.#externalRequest = dependencies.externalRequest;
}
async #fetchJSON(url: URL, options?: {timeout?: number}) {
// Even though we have throwHttpErrors: false, we still need to catch DNS errors
// that can arise from externalRequest, otherwise we'll return a HTTP 500 to the user
try {
// default content type is application/x-www-form-encoded which is what we need for the webmentions spec
const response = await this.#externalRequest.get(url.toString(), {
throwHttpErrors: false,
maxRedirects: 10,
followRedirect: true,
timeout: 15000,
retry: {
// Only retry on network issues, or specific HTTP status codes
limit: 3
},
...options
});
if (response.statusCode >= 200 && response.statusCode < 300) {
try {
return JSON.parse(response.body);
} catch (e) {
return undefined;
}
}
} catch (e) {
return undefined;
}
}
#castUrl(url: string|null|undefined): URL|null {
if (!url) {
return null;
}
try {
return new URL(url);
} catch (e) {
return null;
}
}
async fetch(url: URL, options: {timeout: number} = {timeout: 5000}): Promise<RecommendationMetadata> {
// Make sure url path ends with a slash (urls should be resolved relative to the path)
if (!url.pathname.endsWith('/')) {
url.pathname += '/';
}
// 1. Check if it is a Ghost site
let ghostSiteData = await this.#fetchJSON(
new URL('members/api/site', url),
options
);
if (!ghostSiteData && url.pathname !== '' && url.pathname !== '/') {
// Try root relative URL
ghostSiteData = await this.#fetchJSON(
new URL('members/api/site', url.origin),
options
);
}
if (ghostSiteData && typeof ghostSiteData === 'object' && ghostSiteData.site && typeof ghostSiteData.site === 'object') {
// Check if the Ghost site returns allow_external_signup, otherwise it is an old Ghost version that returns unreliable data
if (typeof ghostSiteData.site.allow_external_signup === 'boolean') {
return {
title: ghostSiteData.site.title || null,
excerpt: ghostSiteData.site.description || null,
featuredImage: this.#castUrl(ghostSiteData.site.cover_image),
favicon: this.#castUrl(ghostSiteData.site.icon || ghostSiteData.site.logo),
oneClickSubscribe: !!ghostSiteData.site.allow_external_signup
};
}
}
// Use the oembed service to fetch metadata
const oembed = await this.#oembedService.fetchOembedDataFromUrl(url.toString(), 'mention');
return {
title: oembed?.metadata?.title || null,
excerpt: oembed?.metadata?.description || null,
featuredImage: this.#castUrl(oembed?.metadata?.thumbnail),
favicon: this.#castUrl(oembed?.metadata?.icon),
oneClickSubscribe: false
};
}
}