@ever-works/jina-plugin 1.0.0 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +358 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +353 -0
- package/package.json +87 -74
- package/LICENSE +0 -661
- package/src/__tests__/jina.plugin.spec.ts +0 -318
- package/src/index.ts +0 -2
- package/src/jina.plugin.ts +0 -398
- package/tsconfig.json +0 -22
- package/tsup.config.ts +0 -12
package/src/jina.plugin.ts
DELETED
|
@@ -1,398 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
IPlugin,
|
|
3
|
-
ISearchPlugin,
|
|
4
|
-
IContentExtractorPlugin,
|
|
5
|
-
PluginContext,
|
|
6
|
-
PluginCategory,
|
|
7
|
-
PluginManifest,
|
|
8
|
-
PluginHealthCheck,
|
|
9
|
-
JsonSchema,
|
|
10
|
-
SearchOptions,
|
|
11
|
-
SearchResponse,
|
|
12
|
-
SearchResult,
|
|
13
|
-
RateLimitInfo,
|
|
14
|
-
ContentExtractionOptions,
|
|
15
|
-
ContentExtractionResult,
|
|
16
|
-
ConnectionValidationResult
|
|
17
|
-
} from '@ever-works/plugin';
|
|
18
|
-
import { isSafeWebhookUrl } from '@ever-works/plugin/helpers/ssrf-guard';
|
|
19
|
-
|
|
20
|
-
const JINA_READER_URL = 'https://r.jina.ai/';
|
|
21
|
-
const JINA_SEARCH_URL = 'https://s.jina.ai/';
|
|
22
|
-
|
|
23
|
-
interface JinaReaderResponse {
|
|
24
|
-
code: number;
|
|
25
|
-
status: number;
|
|
26
|
-
data: {
|
|
27
|
-
title: string;
|
|
28
|
-
description?: string;
|
|
29
|
-
url: string;
|
|
30
|
-
content: string;
|
|
31
|
-
publishedTime?: string;
|
|
32
|
-
images?: Record<string, string>;
|
|
33
|
-
links?: Record<string, string>;
|
|
34
|
-
metadata?: Record<string, string>;
|
|
35
|
-
warning?: string;
|
|
36
|
-
usage?: { tokens: number };
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
interface JinaSearchResult {
|
|
41
|
-
title: string;
|
|
42
|
-
description?: string;
|
|
43
|
-
url: string;
|
|
44
|
-
content?: string;
|
|
45
|
-
date?: string;
|
|
46
|
-
usage?: { tokens: number };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface JinaSearchResponse {
|
|
50
|
-
code: number;
|
|
51
|
-
status: number;
|
|
52
|
-
data: JinaSearchResult[];
|
|
53
|
-
meta?: { usage?: { tokens: number } };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export class JinaReaderPlugin implements IPlugin, ISearchPlugin, IContentExtractorPlugin {
|
|
57
|
-
readonly id = 'jina';
|
|
58
|
-
readonly name = 'Jina AI';
|
|
59
|
-
readonly version = '1.0.0';
|
|
60
|
-
readonly category: PluginCategory = 'content-extractor';
|
|
61
|
-
readonly capabilities: readonly string[] = ['search', 'content-extractor'];
|
|
62
|
-
readonly providerName = 'Jina';
|
|
63
|
-
|
|
64
|
-
readonly settingsSchema: JsonSchema = {
|
|
65
|
-
type: 'object',
|
|
66
|
-
required: ['apiKey'],
|
|
67
|
-
properties: {
|
|
68
|
-
apiKey: {
|
|
69
|
-
type: 'string',
|
|
70
|
-
title: 'API Key',
|
|
71
|
-
description: 'Your Jina API key. Get one at https://jina.ai',
|
|
72
|
-
'x-secret': true,
|
|
73
|
-
'x-envVar': 'PLUGIN_JINA_API_KEY',
|
|
74
|
-
'x-scope': 'user'
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
readonly configurationMode: 'admin-only' | 'user-required' | 'hybrid' = 'hybrid';
|
|
80
|
-
|
|
81
|
-
private context?: PluginContext;
|
|
82
|
-
|
|
83
|
-
async search(options: SearchOptions): Promise<SearchResponse> {
|
|
84
|
-
const startTime = Date.now();
|
|
85
|
-
const apiKey = options.settings?.apiKey as string;
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
const headers: Record<string, string> = {
|
|
89
|
-
Accept: 'application/json',
|
|
90
|
-
'Content-Type': 'application/json',
|
|
91
|
-
'X-Respond-With': 'no-content',
|
|
92
|
-
Authorization: `Bearer ${apiKey}`
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
if (options.includeDomains && options.includeDomains.length > 0) {
|
|
96
|
-
headers['X-Site'] = options.includeDomains[0] as string;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const body: Record<string, unknown> = {
|
|
100
|
-
q: options.query
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
if (options.limit) {
|
|
104
|
-
body.num = options.limit;
|
|
105
|
-
}
|
|
106
|
-
if (options.region) {
|
|
107
|
-
body.gl = options.region;
|
|
108
|
-
}
|
|
109
|
-
if (options.language) {
|
|
110
|
-
body.hl = options.language;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const response = await fetch(JINA_SEARCH_URL, {
|
|
114
|
-
method: 'POST',
|
|
115
|
-
headers,
|
|
116
|
-
body: JSON.stringify(body),
|
|
117
|
-
signal: AbortSignal.timeout(30000)
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
if (!response.ok) {
|
|
121
|
-
throw new Error(`Jina Search API returned ${response.status}: ${response.statusText}`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const json = (await response.json()) as JinaSearchResponse;
|
|
125
|
-
|
|
126
|
-
const results: SearchResult[] = (json.data || []).map((r, index) => {
|
|
127
|
-
let source: string | undefined;
|
|
128
|
-
try {
|
|
129
|
-
if (r.url) source = new URL(r.url).hostname;
|
|
130
|
-
} catch {
|
|
131
|
-
// malformed URL
|
|
132
|
-
}
|
|
133
|
-
return {
|
|
134
|
-
title: r.title || '',
|
|
135
|
-
url: r.url || '',
|
|
136
|
-
snippet: r.description || '',
|
|
137
|
-
position: index + 1,
|
|
138
|
-
publishedDate: r.date || undefined,
|
|
139
|
-
source
|
|
140
|
-
};
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
results,
|
|
145
|
-
query: options.query,
|
|
146
|
-
totalResults: results.length,
|
|
147
|
-
hasMore: false,
|
|
148
|
-
duration: Date.now() - startTime
|
|
149
|
-
};
|
|
150
|
-
} catch (error) {
|
|
151
|
-
this.context?.logger.error(`Jina search failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
152
|
-
throw error;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async isAvailable(): Promise<boolean> {
|
|
157
|
-
if (!this.context) return false;
|
|
158
|
-
const settings = await this.context.getSettings();
|
|
159
|
-
return Boolean(settings?.apiKey);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async validateConnection(settings: Record<string, unknown>): Promise<ConnectionValidationResult> {
|
|
163
|
-
const apiKey = settings.apiKey as string | undefined;
|
|
164
|
-
if (!apiKey) {
|
|
165
|
-
return { success: false, message: 'Jina AI API key is not configured.' };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
const response = await fetch(JINA_SEARCH_URL, {
|
|
170
|
-
method: 'POST',
|
|
171
|
-
headers: {
|
|
172
|
-
Accept: 'application/json',
|
|
173
|
-
'Content-Type': 'application/json',
|
|
174
|
-
'X-Respond-With': 'no-content',
|
|
175
|
-
Authorization: `Bearer ${apiKey}`
|
|
176
|
-
},
|
|
177
|
-
body: JSON.stringify({ q: 'test', num: 1 }),
|
|
178
|
-
signal: AbortSignal.timeout(15000)
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
if (!response.ok) {
|
|
182
|
-
return {
|
|
183
|
-
success: false,
|
|
184
|
-
message: `Jina AI connection failed (${response.status}): ${response.statusText}`
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return { success: true, message: 'Jina AI connection verified.' };
|
|
189
|
-
} catch (error) {
|
|
190
|
-
return {
|
|
191
|
-
success: false,
|
|
192
|
-
message: `Jina AI connection failed: ${error instanceof Error ? error.message : String(error)}`
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
async getRateLimitInfo(): Promise<RateLimitInfo> {
|
|
198
|
-
return {
|
|
199
|
-
remaining: -1,
|
|
200
|
-
limit: -1,
|
|
201
|
-
period: 'minute'
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
async extract(options: ContentExtractionOptions): Promise<ContentExtractionResult> {
|
|
206
|
-
const startTime = Date.now();
|
|
207
|
-
const { url, settings } = options;
|
|
208
|
-
const apiKey = settings?.apiKey as string;
|
|
209
|
-
|
|
210
|
-
// Security (SSRF): the caller-supplied `url` is forwarded verbatim to the
|
|
211
|
-
// Jina Reader API (r.jina.ai), which fetches it server-side and returns the
|
|
212
|
-
// response body to us. Without a pre-check, a hostile tenant could target
|
|
213
|
-
// internal/loopback/link-local hosts or cloud-metadata IPs (e.g.
|
|
214
|
-
// http://169.254.169.254/...) and exfiltrate the response. Apply the same
|
|
215
|
-
// lexical SSRF guard the default local-content-extractor uses before making
|
|
216
|
-
// the request. Returns a failed result (the method's existing error shape).
|
|
217
|
-
if (!isSafeWebhookUrl(url)) {
|
|
218
|
-
return {
|
|
219
|
-
success: false,
|
|
220
|
-
url,
|
|
221
|
-
error: `URL host is not safe to fetch (SSRF guard blocked: ${url})`,
|
|
222
|
-
duration: Date.now() - startTime
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
try {
|
|
227
|
-
const headers: Record<string, string> = {
|
|
228
|
-
Accept: 'application/json',
|
|
229
|
-
'X-Respond-With': 'markdown',
|
|
230
|
-
Authorization: `Bearer ${apiKey}`
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
const response = await fetch(JINA_READER_URL, {
|
|
234
|
-
method: 'POST',
|
|
235
|
-
headers: {
|
|
236
|
-
...headers,
|
|
237
|
-
'Content-Type': 'application/json'
|
|
238
|
-
},
|
|
239
|
-
body: JSON.stringify({ url }),
|
|
240
|
-
signal: AbortSignal.timeout(options.timeout || 30000)
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
if (!response.ok) {
|
|
244
|
-
return {
|
|
245
|
-
success: false,
|
|
246
|
-
url,
|
|
247
|
-
error: `Jina API returned ${response.status}: ${response.statusText}`,
|
|
248
|
-
duration: Date.now() - startTime
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const json = (await response.json()) as JinaReaderResponse;
|
|
253
|
-
const data = json.data;
|
|
254
|
-
|
|
255
|
-
if (!data || !data.content) {
|
|
256
|
-
return {
|
|
257
|
-
success: false,
|
|
258
|
-
url,
|
|
259
|
-
error: 'No content returned from Jina API',
|
|
260
|
-
duration: Date.now() - startTime
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const wordCount = data.content.split(/\s+/).filter((w) => w.length > 0).length;
|
|
265
|
-
|
|
266
|
-
const images = data.images ? Object.entries(data.images).map(([alt, src]) => ({ src, alt })) : undefined;
|
|
267
|
-
|
|
268
|
-
const links = data.links
|
|
269
|
-
? Object.entries(data.links).map(([text, href]) => ({
|
|
270
|
-
href,
|
|
271
|
-
text,
|
|
272
|
-
isExternal: true
|
|
273
|
-
}))
|
|
274
|
-
: undefined;
|
|
275
|
-
|
|
276
|
-
const metadata: Record<string, unknown> = {};
|
|
277
|
-
if (data.description) metadata.description = data.description;
|
|
278
|
-
if (data.publishedTime) metadata.publishedDate = data.publishedTime;
|
|
279
|
-
if (data.metadata?.lang) metadata.language = data.metadata.lang;
|
|
280
|
-
|
|
281
|
-
return {
|
|
282
|
-
success: true,
|
|
283
|
-
url,
|
|
284
|
-
finalUrl: data.url !== url ? data.url : undefined,
|
|
285
|
-
title: data.title,
|
|
286
|
-
content: data.content,
|
|
287
|
-
markdown: data.content,
|
|
288
|
-
images: options.includeImages !== false ? images : undefined,
|
|
289
|
-
links: options.includeLinks !== false ? links : undefined,
|
|
290
|
-
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
291
|
-
duration: Date.now() - startTime,
|
|
292
|
-
wordCount,
|
|
293
|
-
readingTime: Math.ceil(wordCount / 200)
|
|
294
|
-
};
|
|
295
|
-
} catch (error) {
|
|
296
|
-
return {
|
|
297
|
-
success: false,
|
|
298
|
-
url,
|
|
299
|
-
error: error instanceof Error ? error.message : String(error),
|
|
300
|
-
duration: Date.now() - startTime
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
async extractBatch(
|
|
306
|
-
urls: readonly string[],
|
|
307
|
-
options?: Partial<ContentExtractionOptions>
|
|
308
|
-
): Promise<readonly ContentExtractionResult[]> {
|
|
309
|
-
const batchSize = 5;
|
|
310
|
-
const results: ContentExtractionResult[] = [];
|
|
311
|
-
|
|
312
|
-
for (let i = 0; i < urls.length; i += batchSize) {
|
|
313
|
-
const batch = urls.slice(i, i + batchSize);
|
|
314
|
-
const batchResults = await Promise.all(batch.map((url) => this.extract({ url, ...options })));
|
|
315
|
-
results.push(...batchResults);
|
|
316
|
-
|
|
317
|
-
if (i + batchSize < urls.length) {
|
|
318
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
return results;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
async canExtract(url: string): Promise<boolean> {
|
|
326
|
-
// Security (SSRF): reject http(s) URLs that resolve to private, loopback,
|
|
327
|
-
// link-local, or cloud-metadata hosts so the facade never selects this
|
|
328
|
-
// provider for an internal target. `isSafeWebhookUrl` already enforces the
|
|
329
|
-
// http/https-only check the previous protocol guard did, while adding the
|
|
330
|
-
// private-IP/metadata denylist. Public URLs are unaffected.
|
|
331
|
-
return isSafeWebhookUrl(url);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
getSupportedFormats(): readonly ('text' | 'html' | 'markdown')[] {
|
|
335
|
-
return ['text', 'markdown'];
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
async onLoad(context: PluginContext): Promise<void> {
|
|
339
|
-
this.context = context;
|
|
340
|
-
context.logger.log('Jina AI Reader Plugin loaded');
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
async onUnload(): Promise<void> {
|
|
344
|
-
this.context = undefined;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
async healthCheck(): Promise<PluginHealthCheck> {
|
|
348
|
-
return {
|
|
349
|
-
status: 'healthy',
|
|
350
|
-
message: 'Jina AI Reader plugin is ready',
|
|
351
|
-
checkedAt: Date.now()
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
getManifest(): PluginManifest {
|
|
356
|
-
return {
|
|
357
|
-
id: this.id,
|
|
358
|
-
name: this.name,
|
|
359
|
-
version: this.version,
|
|
360
|
-
description: 'Web search and content extraction using the Jina AI APIs',
|
|
361
|
-
category: this.category,
|
|
362
|
-
capabilities: [...this.capabilities],
|
|
363
|
-
author: { name: 'Ever Works Team' },
|
|
364
|
-
license: 'AGPL-3.0',
|
|
365
|
-
builtIn: true,
|
|
366
|
-
systemPlugin: false,
|
|
367
|
-
autoEnable: false,
|
|
368
|
-
homepage: 'https://jina.ai',
|
|
369
|
-
icon: {
|
|
370
|
-
type: 'url',
|
|
371
|
-
value: 'https://jina.ai/icons/favicon-128x128.png'
|
|
372
|
-
},
|
|
373
|
-
readme: [
|
|
374
|
-
'## What does Jina AI do?',
|
|
375
|
-
'',
|
|
376
|
-
'Jina AI provides web search with LLM-optimized results and content extraction that converts any web page into clean markdown.',
|
|
377
|
-
'',
|
|
378
|
-
'## Why use it?',
|
|
379
|
-
'',
|
|
380
|
-
'- **Web search** — search the web and get results with content already extracted',
|
|
381
|
-
'- **Content extraction** — converts pages to clean markdown, strips ads and navigation',
|
|
382
|
-
'- **Domain filtering** — restrict search to specific domains',
|
|
383
|
-
'',
|
|
384
|
-
'## How it works in Ever Works',
|
|
385
|
-
'',
|
|
386
|
-
'During work generation, Jina finds relevant information about each item via search and extracts clean content from web pages for enriching descriptions.',
|
|
387
|
-
'',
|
|
388
|
-
'## Getting started',
|
|
389
|
-
'',
|
|
390
|
-
'1. Get an API key at [jina.ai](https://jina.ai)',
|
|
391
|
-
'2. Enter the key in the **API Key** field below',
|
|
392
|
-
'3. Enable the plugin'
|
|
393
|
-
].join('\n')
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
export default JinaReaderPlugin;
|
package/tsconfig.json
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"types": ["node"],
|
|
5
|
-
"module": "NodeNext",
|
|
6
|
-
"moduleResolution": "NodeNext",
|
|
7
|
-
"declaration": true,
|
|
8
|
-
"declarationMap": true,
|
|
9
|
-
"sourceMap": true,
|
|
10
|
-
"outDir": "./dist",
|
|
11
|
-
"rootDir": "./src",
|
|
12
|
-
"strict": true,
|
|
13
|
-
"esModuleInterop": true,
|
|
14
|
-
"skipLibCheck": true,
|
|
15
|
-
"forceConsistentCasingInFileNames": true,
|
|
16
|
-
"resolveJsonModule": true,
|
|
17
|
-
"isolatedModules": true,
|
|
18
|
-
"noEmit": true
|
|
19
|
-
},
|
|
20
|
-
"include": ["src/**/*"],
|
|
21
|
-
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
|
22
|
-
}
|
package/tsup.config.ts
DELETED