@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.
@@ -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
@@ -1,12 +0,0 @@
1
- import { defineConfig } from 'tsup';
2
-
3
- export default defineConfig({
4
- entry: ['src/index.ts'],
5
- noExternal: ['@ever-works/plugin'],
6
- format: ['cjs', 'esm'],
7
- dts: true,
8
- clean: true,
9
- sourcemap: false,
10
- splitting: false,
11
- treeshake: true
12
- });