@ever-works/jina-plugin 1.0.0

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/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @ever-works/jina-plugin
2
+
3
+ Jina AI Plugin - Web search and content extraction using Jina AI APIs
4
+
5
+ ## Plugin metadata
6
+
7
+ | Field | Value |
8
+ | ------------ | ----------------------------- |
9
+ | ID | `jina` |
10
+ | Category | `content-extractor` |
11
+ | Capabilities | `search`, `content-extractor` |
12
+ | Author | Ever Works Team |
13
+ | License | AGPL-3.0 |
14
+ | Built-in | yes |
15
+ | Auto-enable | no |
16
+
17
+ ## What does Jina AI do?
18
+
19
+ Jina AI provides web search with LLM-optimized results and content extraction that converts any web page into clean markdown.
20
+
21
+ ## Why use it?
22
+
23
+ - **Web search** — search the web and get results with content already extracted
24
+ - **Content extraction** — converts pages to clean markdown, strips ads and navigation
25
+ - **Domain filtering** — restrict search to specific domains
26
+
27
+ ## How it works in Ever Works
28
+
29
+ During work generation, Jina finds relevant information about each item via search and extracts clean content from web pages for enriching descriptions.
30
+
31
+ ## Getting started
32
+
33
+ 1. Get an API key at [jina.ai](https://jina.ai)
34
+ 2. Enter the key in the **API Key** field below
35
+ 3. Enable the plugin
36
+
37
+ ## Settings
38
+
39
+ - **API Key** (`apiKey`) — required, secret. Your Jina API key (also configurable via the `PLUGIN_JINA_API_KEY` environment variable).
40
+ - Stored at user scope so each user can supply their own credentials.
41
+ - Configuration mode is `hybrid` — admins can preset values and users may override them.
42
+
43
+ ## Troubleshooting
44
+
45
+ | Symptom | Likely cause | Fix |
46
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
47
+ | `401` / `403` from the extractor | API key / token missing or revoked | Re-enter the credential from the Jina AI dashboard, or set `PLUGIN_JINA_API_KEY` in the host environment for default fallback |
48
+ | `Failed to extract content` for a specific URL | Page requires authentication, JavaScript rendering, or a custom client (Notion, PDF, login wall) | Verify the URL is publicly reachable; if it requires JavaScript/auth, switch to a more capable extractor (`scrapfly` / `notion-extractor` / `pdf-extractor`) for that URL |
49
+ | Plugin not used during extraction | Another content-extractor plugin is set as the default | In **Settings → Plugins**, set `jina` as the default for `content-extractor`, or disable competing plugins |
50
+ | `healthCheck` reports unhealthy | Credential invalid OR Jina AI endpoint unreachable from the host | Verify the credential with a manual call to the upstream API and confirm outbound HTTPS is allowed by the firewall |
51
+
52
+ ## Local development
53
+
54
+ This plugin ships built-in with the Ever Works platform. To work on it locally from the monorepo root:
55
+
56
+ ```bash
57
+ pnpm install
58
+ pnpm --filter @ever-works/jina-plugin build
59
+ pnpm --filter @ever-works/jina-plugin test
60
+ ```
61
+
62
+ ## Documentation
63
+
64
+ - [Ever Works documentation](https://docs.ever.works)
65
+ - [Ever Works repository](https://github.com/ever-works/ever-works)
66
+ - [Plugin system](../../plugin/README.md)
67
+ - [Jina AI homepage](https://jina.ai)
68
+
69
+ ## License
70
+
71
+ AGPL-3.0
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@ever-works/jina-plugin",
3
+ "version": "1.0.0",
4
+ "description": "Jina AI Plugin - Web search and content extraction using Jina AI APIs",
5
+ "author": {
6
+ "name": "Ever Co. LTD",
7
+ "email": "ever@ever.co",
8
+ "url": "https://ever.co"
9
+ },
10
+ "license": "AGPL-3.0",
11
+ "private": false,
12
+ "type": "module",
13
+ "main": "./dist/index.cjs",
14
+ "module": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "require": "./dist/index.cjs"
21
+ }
22
+ },
23
+ "peerDependencies": {
24
+ "@ever-works/plugin": "1.1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.0.0",
28
+ "tsup": "^8.4.0",
29
+ "typescript": "^5.7.3",
30
+ "vitest": "^4.1.0",
31
+ "@ever-works/plugin": "1.1.0"
32
+ },
33
+ "everworks": {
34
+ "plugin": {
35
+ "id": "jina",
36
+ "name": "Jina AI",
37
+ "version": "1.0.0",
38
+ "category": "content-extractor",
39
+ "capabilities": [
40
+ "search",
41
+ "content-extractor"
42
+ ],
43
+ "description": "Web search and content extraction using Jina AI APIs. Search via s.jina.ai and extract content via r.jina.ai, no SDK needed.",
44
+ "author": {
45
+ "name": "Ever Works Team"
46
+ },
47
+ "license": "AGPL-3.0",
48
+ "autoEnable": false,
49
+ "systemPlugin": false,
50
+ "builtIn": true,
51
+ "envVars": [
52
+ {
53
+ "name": "PLUGIN_JINA_API_KEY",
54
+ "required": true,
55
+ "secret": true,
56
+ "description": "Jina API key"
57
+ }
58
+ ],
59
+ "distribution": "registry"
60
+ }
61
+ },
62
+ "publishConfig": {
63
+ "access": "restricted",
64
+ "registry": "https://registry.npmjs.org"
65
+ },
66
+ "scripts": {
67
+ "build": "tsc --noEmit && tsup",
68
+ "dev": "tsup --watch",
69
+ "type-check": "tsc --noEmit",
70
+ "clean": "rm -rf dist",
71
+ "test": "vitest run --passWithNoTests",
72
+ "test:watch": "vitest",
73
+ "test:coverage": "vitest run --coverage"
74
+ }
75
+ }
@@ -0,0 +1,318 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { JinaReaderPlugin } from '../jina.plugin.js';
3
+ import type { PluginContext, SearchOptions, ContentExtractionOptions } from '@ever-works/plugin';
4
+
5
+ const buildContext = (settings: Record<string, unknown> = {}): PluginContext =>
6
+ ({
7
+ pluginId: 'jina',
8
+ logger: {
9
+ log: vi.fn(),
10
+ debug: vi.fn(),
11
+ warn: vi.fn(),
12
+ error: vi.fn()
13
+ },
14
+ getSettings: vi.fn().mockResolvedValue(settings)
15
+ }) as unknown as PluginContext;
16
+
17
+ const okResponse = (body: unknown): Response =>
18
+ ({
19
+ ok: true,
20
+ status: 200,
21
+ statusText: 'OK',
22
+ json: () => Promise.resolve(body),
23
+ text: () => Promise.resolve(JSON.stringify(body))
24
+ }) as unknown as Response;
25
+
26
+ const errResponse = (status: number, statusText = 'error'): Response =>
27
+ ({
28
+ ok: false,
29
+ status,
30
+ statusText,
31
+ json: () => Promise.resolve({}),
32
+ text: () => Promise.resolve(statusText)
33
+ }) as unknown as Response;
34
+
35
+ describe('JinaReaderPlugin', () => {
36
+ let plugin: JinaReaderPlugin;
37
+ let fetchMock: ReturnType<typeof vi.fn>;
38
+ const originalFetch = globalThis.fetch;
39
+
40
+ beforeEach(() => {
41
+ plugin = new JinaReaderPlugin();
42
+ fetchMock = vi.fn();
43
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
44
+ });
45
+
46
+ afterEach(() => {
47
+ globalThis.fetch = originalFetch;
48
+ vi.clearAllMocks();
49
+ });
50
+
51
+ describe('metadata', () => {
52
+ it('exposes stable identity fields', () => {
53
+ expect(plugin.id).toBe('jina');
54
+ expect(plugin.name).toBe('Jina AI');
55
+ expect(plugin.version).toBe('1.0.0');
56
+ expect(plugin.category).toBe('content-extractor');
57
+ expect(plugin.providerName).toBe('Jina');
58
+ });
59
+
60
+ it('declares both search and content-extractor capabilities', () => {
61
+ expect(plugin.capabilities).toEqual(['search', 'content-extractor']);
62
+ });
63
+ });
64
+
65
+ describe('settingsSchema', () => {
66
+ it('requires apiKey and marks it as user-scoped secret', () => {
67
+ expect(plugin.settingsSchema.required).toContain('apiKey');
68
+ const props = plugin.settingsSchema.properties as Record<string, Record<string, unknown>>;
69
+ expect(props.apiKey['x-secret']).toBe(true);
70
+ expect(props.apiKey['x-envVar']).toBe('PLUGIN_JINA_API_KEY');
71
+ });
72
+ });
73
+
74
+ describe('search', () => {
75
+ const opts = (overrides: Partial<SearchOptions> = {}): SearchOptions => ({
76
+ query: 'hello',
77
+ settings: { apiKey: 'k' },
78
+ ...overrides
79
+ });
80
+
81
+ it('returns mapped results with derived hostname source', async () => {
82
+ fetchMock.mockResolvedValueOnce(
83
+ okResponse({
84
+ data: [
85
+ {
86
+ title: 'Example',
87
+ url: 'https://www.example.com/post',
88
+ description: 'snippet here',
89
+ date: '2026-01-01'
90
+ }
91
+ ]
92
+ })
93
+ );
94
+
95
+ const r = await plugin.search(opts({ limit: 5, region: 'us', language: 'en' }));
96
+
97
+ expect(r.results).toHaveLength(1);
98
+ expect(r.results[0]).toMatchObject({
99
+ title: 'Example',
100
+ url: 'https://www.example.com/post',
101
+ snippet: 'snippet here',
102
+ position: 1,
103
+ publishedDate: '2026-01-01',
104
+ source: 'www.example.com'
105
+ });
106
+
107
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
108
+ expect(url).toBe('https://s.jina.ai/');
109
+ expect(init.method).toBe('POST');
110
+ expect((init.headers as Record<string, string>).Authorization).toBe('Bearer k');
111
+ const body = JSON.parse(init.body as string);
112
+ expect(body.q).toBe('hello');
113
+ expect(body.num).toBe(5);
114
+ expect(body.gl).toBe('us');
115
+ expect(body.hl).toBe('en');
116
+ });
117
+
118
+ it('forwards X-Site header for the first includeDomain', async () => {
119
+ fetchMock.mockResolvedValueOnce(okResponse({ data: [] }));
120
+ await plugin.search(opts({ includeDomains: ['a.com', 'b.com'] }));
121
+ const init = fetchMock.mock.calls[0][1] as RequestInit;
122
+ expect((init.headers as Record<string, string>)['X-Site']).toBe('a.com');
123
+ });
124
+
125
+ it('handles missing data array safely', async () => {
126
+ fetchMock.mockResolvedValueOnce(okResponse({}));
127
+ const r = await plugin.search(opts());
128
+ expect(r.results).toEqual([]);
129
+ expect(r.totalResults).toBe(0);
130
+ });
131
+
132
+ it('throws and logs on non-OK upstream', async () => {
133
+ await plugin.onLoad(buildContext());
134
+ fetchMock.mockResolvedValueOnce(errResponse(503, 'Service Unavailable'));
135
+ await expect(plugin.search(opts())).rejects.toThrow(/Jina Search API returned 503/);
136
+ });
137
+ });
138
+
139
+ describe('isAvailable', () => {
140
+ it('returns false without context', async () => {
141
+ expect(await plugin.isAvailable()).toBe(false);
142
+ });
143
+
144
+ it('reflects whether settings has an apiKey', async () => {
145
+ await plugin.onLoad(buildContext({ apiKey: 'k' }));
146
+ expect(await plugin.isAvailable()).toBe(true);
147
+
148
+ await plugin.onLoad(buildContext({}));
149
+ expect(await plugin.isAvailable()).toBe(false);
150
+ });
151
+ });
152
+
153
+ describe('validateConnection', () => {
154
+ it('fails fast without apiKey', async () => {
155
+ const r = await plugin.validateConnection({});
156
+ expect(r.success).toBe(false);
157
+ expect(r.message).toMatch(/not configured/i);
158
+ expect(fetchMock).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('returns success on a 200 response', async () => {
162
+ fetchMock.mockResolvedValueOnce(okResponse({ data: [] }));
163
+ const r = await plugin.validateConnection({ apiKey: 'k' });
164
+ expect(r.success).toBe(true);
165
+ });
166
+
167
+ it('returns failure on a non-OK response', async () => {
168
+ fetchMock.mockResolvedValueOnce(errResponse(401, 'Unauthorized'));
169
+ const r = await plugin.validateConnection({ apiKey: 'k' });
170
+ expect(r.success).toBe(false);
171
+ expect(r.message).toMatch(/401/);
172
+ });
173
+
174
+ it('returns failure when fetch throws', async () => {
175
+ fetchMock.mockRejectedValueOnce(new Error('boom'));
176
+ const r = await plugin.validateConnection({ apiKey: 'k' });
177
+ expect(r.success).toBe(false);
178
+ expect(r.message).toMatch(/boom/);
179
+ });
180
+ });
181
+
182
+ describe('extract', () => {
183
+ const opts = (overrides: Partial<ContentExtractionOptions> = {}): ContentExtractionOptions => ({
184
+ url: 'https://example.com/post',
185
+ settings: { apiKey: 'k' },
186
+ ...overrides
187
+ });
188
+
189
+ it('returns success with title, content, images, links, metadata, wordCount, readingTime', async () => {
190
+ fetchMock.mockResolvedValueOnce(
191
+ okResponse({
192
+ data: {
193
+ title: 'Title',
194
+ url: 'https://example.com/post',
195
+ content: 'one two three four five',
196
+ description: 'desc',
197
+ publishedTime: '2026-01-01',
198
+ images: { 'alt one': 'https://img/1.png' },
199
+ links: { 'link one': 'https://target.example' },
200
+ metadata: { lang: 'en' }
201
+ }
202
+ })
203
+ );
204
+
205
+ const r = await plugin.extract(opts());
206
+ expect(r.success).toBe(true);
207
+ expect(r.title).toBe('Title');
208
+ expect(r.markdown).toBe('one two three four five');
209
+ expect(r.wordCount).toBe(5);
210
+ expect(r.readingTime).toBe(1);
211
+ expect(r.images).toHaveLength(1);
212
+ expect(r.images?.[0]).toMatchObject({ src: 'https://img/1.png', alt: 'alt one' });
213
+ expect(r.links?.[0]).toMatchObject({ href: 'https://target.example', text: 'link one' });
214
+ expect(r.metadata).toMatchObject({
215
+ description: 'desc',
216
+ publishedDate: '2026-01-01',
217
+ language: 'en'
218
+ });
219
+ });
220
+
221
+ it('exposes finalUrl when SDK returns a different URL', async () => {
222
+ fetchMock.mockResolvedValueOnce(
223
+ okResponse({
224
+ data: {
225
+ title: 't',
226
+ url: 'https://example.com/redirected',
227
+ content: 'hi'
228
+ }
229
+ })
230
+ );
231
+ const r = await plugin.extract(opts());
232
+ expect(r.finalUrl).toBe('https://example.com/redirected');
233
+ });
234
+
235
+ it('returns failure when content is missing', async () => {
236
+ fetchMock.mockResolvedValueOnce(okResponse({ data: { title: 't', url: 'u' } }));
237
+ const r = await plugin.extract(opts());
238
+ expect(r.success).toBe(false);
239
+ expect(r.error).toMatch(/No content/);
240
+ });
241
+
242
+ it('returns failure when upstream returns non-OK', async () => {
243
+ fetchMock.mockResolvedValueOnce(errResponse(429, 'Too Many Requests'));
244
+ const r = await plugin.extract(opts());
245
+ expect(r.success).toBe(false);
246
+ expect(r.error).toMatch(/429/);
247
+ });
248
+
249
+ it('omits images when includeImages is false', async () => {
250
+ fetchMock.mockResolvedValueOnce(
251
+ okResponse({
252
+ data: {
253
+ title: 't',
254
+ url: 'u',
255
+ content: 'hi',
256
+ images: { foo: 'https://i' }
257
+ }
258
+ })
259
+ );
260
+ const r = await plugin.extract(opts({ includeImages: false }));
261
+ expect(r.images).toBeUndefined();
262
+ });
263
+ });
264
+
265
+ describe('extractBatch', () => {
266
+ it('chunks URLs into batches of 5', async () => {
267
+ const urls = Array.from({ length: 7 }, (_, i) => `https://example.com/${i}`);
268
+ for (const u of urls) {
269
+ fetchMock.mockResolvedValueOnce(okResponse({ data: { title: 't', url: u, content: 'a b' } }));
270
+ }
271
+ const r = await plugin.extractBatch(urls, { settings: { apiKey: 'k' } });
272
+ expect(r).toHaveLength(7);
273
+ expect(r.every((x) => x.success === true)).toBe(true);
274
+ expect(fetchMock).toHaveBeenCalledTimes(7);
275
+ });
276
+ });
277
+
278
+ describe('canExtract / getSupportedFormats', () => {
279
+ it('accepts http and https URLs', async () => {
280
+ expect(await plugin.canExtract('https://example.com')).toBe(true);
281
+ expect(await plugin.canExtract('http://example.com')).toBe(true);
282
+ });
283
+
284
+ it('rejects non-http schemes and invalid URLs', async () => {
285
+ expect(await plugin.canExtract('ftp://example.com')).toBe(false);
286
+ expect(await plugin.canExtract('garbage')).toBe(false);
287
+ });
288
+
289
+ it('exposes text + markdown formats', () => {
290
+ expect(plugin.getSupportedFormats()).toEqual(['text', 'markdown']);
291
+ });
292
+ });
293
+
294
+ describe('lifecycle', () => {
295
+ it('logs on load and clears context on unload', async () => {
296
+ const ctx = buildContext();
297
+ await plugin.onLoad(ctx);
298
+ expect(ctx.logger.log).toHaveBeenCalledWith('Jina AI Reader Plugin loaded');
299
+ await plugin.onUnload();
300
+ expect(await plugin.isAvailable()).toBe(false);
301
+ });
302
+ });
303
+
304
+ describe('healthCheck + manifest', () => {
305
+ it('reports healthy', async () => {
306
+ const h = await plugin.healthCheck();
307
+ expect(h.status).toBe('healthy');
308
+ });
309
+
310
+ it('returns a manifest aligned with plugin metadata', () => {
311
+ const m = plugin.getManifest();
312
+ expect(m.id).toBe('jina');
313
+ expect(m.category).toBe('content-extractor');
314
+ expect(m.capabilities).toEqual(['search', 'content-extractor']);
315
+ expect(m.builtIn).toBe(true);
316
+ });
317
+ });
318
+ });
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { JinaReaderPlugin } from './jina.plugin.js';
2
+ export { JinaReaderPlugin as default } from './jina.plugin.js';