A social knowledge tool for researchers built on ATProto
1import { IMetadataService } from '../domain/services/IMetadataService';
2import { UrlMetadata } from '../domain/value-objects/UrlMetadata';
3import { URL } from '../domain/value-objects/URL';
4import { Result, err } from '../../../shared/core/Result';
5
6interface IFramelyMeta {
7 title?: string;
8 description?: string;
9 author?: string;
10 author_url?: string;
11 site?: string;
12 canonical?: string;
13 duration?: number;
14 date?: string;
15 medium?: string;
16}
17
18interface IFramelyLinks {
19 thumbnail?: Array<{
20 href: string;
21 media?: {
22 height?: number;
23 width?: number;
24 };
25 }>;
26}
27
28interface IFramelyResponse {
29 id?: string;
30 url?: string;
31 meta?: IFramelyMeta;
32 links?: IFramelyLinks;
33 html?: string;
34 error?: string;
35}
36
37export class IFramelyMetadataService implements IMetadataService {
38 private readonly baseUrl = 'https://iframe.ly/api/iframely';
39 private readonly apiKey: string;
40
41 constructor(apiKey: string) {
42 if (!apiKey || apiKey.trim().length === 0) {
43 throw new Error('Iframely API key is required');
44 }
45 this.apiKey = apiKey;
46 }
47
48 async fetchMetadata(url: URL): Promise<Result<UrlMetadata>> {
49 try {
50 const encodedUrl = encodeURIComponent(url.value);
51 const fullUrl = `${this.baseUrl}?url=${encodedUrl}&api_key=${this.apiKey}`;
52
53 const response = await fetch(fullUrl, {
54 method: 'GET',
55 headers: {
56 accept: 'application/json',
57 },
58 });
59
60 if (!response.ok) {
61 return err(
62 new Error(
63 `Iframely API request failed with status ${response.status} and message: ${response.statusText}`,
64 ),
65 );
66 }
67
68 const data = (await response.json()) as IFramelyResponse;
69
70 // Check if the response contains an error
71 if (data.error) {
72 return err(new Error(`Iframely service error: ${data.error}`));
73 }
74
75 // Check if we have meta data
76 if (!data.meta) {
77 return err(new Error('No metadata found for the given URL'));
78 }
79
80 const meta = data.meta;
81
82 // Parse published date
83 const publishedDate = meta.date ? this.parseDate(meta.date) : undefined;
84
85 // Extract image URL from thumbnail links
86 const imageUrl = this.extractImageUrl(data.links);
87
88 const metadataResult = UrlMetadata.create({
89 url: url.value,
90 title: meta.title,
91 description: meta.description,
92 author: meta.author,
93 publishedDate,
94 siteName: meta.site,
95 imageUrl,
96 type: meta.medium,
97 });
98
99 return metadataResult;
100 } catch (error) {
101 return err(
102 new Error(
103 `Failed to fetch metadata from Iframely: ${error instanceof Error ? error.message : 'Unknown error'}`,
104 ),
105 );
106 }
107 }
108
109 async isAvailable(): Promise<boolean> {
110 try {
111 // Test with a simple URL to check if the service is available
112 const testUrl = `${this.baseUrl}?url=${encodeURIComponent('https://example.com')}&api_key=${this.apiKey}`;
113 const response = await fetch(testUrl, {
114 method: 'HEAD',
115 });
116
117 // Service is available if we get any response (even 4xx errors are fine)
118 return response.status < 500;
119 } catch {
120 return false;
121 }
122 }
123
124 private parseDate(dateString: string): Date | undefined {
125 try {
126 // Try to parse the date string
127 const parsed = new Date(dateString);
128
129 // Check if the date is valid
130 if (isNaN(parsed.getTime())) {
131 return undefined;
132 }
133
134 return parsed;
135 } catch {
136 return undefined;
137 }
138 }
139
140 private extractImageUrl(links?: IFramelyLinks): string | undefined {
141 if (!links || !links.thumbnail || links.thumbnail.length === 0) {
142 return undefined;
143 }
144
145 // Return the first thumbnail URL
146 return links.thumbnail[0]?.href;
147 }
148}