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, ok, err } from '../../../shared/core/Result';
5
6interface CitoidResponse {
7 key?: string;
8 version?: number;
9 itemType?: string;
10 creators?: Array<{
11 firstName?: string;
12 lastName?: string;
13 creatorType?: string;
14 }>;
15 tags?: string[];
16 title?: string;
17 date?: string;
18 url?: string;
19 abstractNote?: string;
20 publicationTitle?: string;
21 language?: string;
22 libraryCatalog?: string;
23 accessDate?: string;
24}
25
26interface CitoidErrorResponse {
27 Error: string;
28}
29
30export class CitoidMetadataService implements IMetadataService {
31 private readonly baseUrl =
32 'https://en.wikipedia.org/api/rest_v1/data/citation/zotero/';
33 private readonly headers = {
34 accept: 'application/json; charset=utf-8;',
35 };
36
37 async fetchMetadata(url: URL): Promise<Result<UrlMetadata>> {
38 try {
39 // URL-encode the target URL
40 const encodedUrl = encodeURIComponent(url.value);
41 const fullUrl = this.baseUrl + encodedUrl;
42
43 const response = await fetch(fullUrl, {
44 method: 'GET',
45 headers: this.headers,
46 });
47
48 if (!response.ok) {
49 return err(
50 new Error(
51 `Citoid API request failed with status ${response.status} and message: ${response.statusText}`,
52 ),
53 );
54 }
55
56 const data = (await response.json()) as any;
57
58 // Check if the response is an error
59 if (data && typeof data === 'object' && 'Error' in data) {
60 const errorResponse = data as CitoidErrorResponse;
61 return err(new Error(`Citoid service error: ${errorResponse.Error}`));
62 }
63
64 // Check if it's an array response
65 if (!Array.isArray(data) || data.length === 0) {
66 return err(new Error('No metadata found for the given URL'));
67 }
68
69 // Use the first result
70 const citoidData = data[0] as CitoidResponse;
71 if (!citoidData || !citoidData.itemType) {
72 return err(new Error('Invalid metadata format from Citoid'));
73 }
74
75 // Extract author from creators array
76 const author =
77 citoidData.creators && citoidData.creators.length > 0
78 ? this.formatAuthor(citoidData.creators[0]!)
79 : undefined;
80
81 // Parse published date
82 const publishedDate = citoidData.date
83 ? this.parseDate(citoidData.date)
84 : undefined;
85
86 const metadataResult = UrlMetadata.create({
87 url: url.value,
88 title: citoidData.title,
89 description: citoidData.abstractNote,
90 author,
91 publishedDate,
92 siteName: citoidData.publicationTitle || citoidData.libraryCatalog,
93 type: citoidData.itemType,
94 });
95
96 return metadataResult;
97 } catch (error) {
98 return err(
99 new Error(
100 `Failed to fetch metadata from Citoid: ${error instanceof Error ? error.message : 'Unknown error'}`,
101 ),
102 );
103 }
104 }
105
106 async isAvailable(): Promise<boolean> {
107 try {
108 // Test with a simple URL to check if the service is available
109 const testUrl = this.baseUrl + encodeURIComponent('https://example.com');
110 const response = await fetch(testUrl, {
111 method: 'HEAD',
112 headers: this.headers,
113 });
114
115 // Service is available if we get any response (even 4xx errors are fine)
116 return response.status < 500;
117 } catch {
118 return false;
119 }
120 }
121
122 private formatAuthor(creator: {
123 firstName?: string;
124 lastName?: string;
125 }): string {
126 const { firstName, lastName } = creator;
127
128 if (firstName && lastName) {
129 return `${firstName} ${lastName}`;
130 } else if (lastName) {
131 return lastName;
132 } else if (firstName) {
133 return firstName;
134 }
135
136 return '';
137 }
138
139 private parseDate(dateString: string): Date | undefined {
140 try {
141 // Try to parse the date string
142 const parsed = new Date(dateString);
143
144 // Check if the date is valid
145 if (isNaN(parsed.getTime())) {
146 return undefined;
147 }
148
149 return parsed;
150 } catch {
151 return undefined;
152 }
153 }
154}