@jackwener/opencli 1.7.19 → 1.7.21
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 +11 -9
- package/README.zh-CN.md +9 -10
- package/cli-manifest.json +239 -249
- package/clis/_shared/search-adapter.js +70 -0
- package/clis/boss/chatlist.js +96 -14
- package/clis/boss/chatlist.test.js +211 -0
- package/clis/boss/chatmsg.js +98 -24
- package/clis/boss/chatmsg.test.js +230 -0
- package/clis/boss/utils.js +240 -11
- package/clis/brave/search.js +80 -0
- package/clis/brave/search.test.js +76 -0
- package/clis/duckduckgo/search.js +131 -0
- package/clis/duckduckgo/search.test.js +128 -0
- package/clis/duckduckgo/suggest.js +45 -0
- package/clis/duckduckgo/suggest.test.js +66 -0
- package/clis/facebook/feed.js +301 -56
- package/clis/facebook/feed.test.js +169 -0
- package/clis/reddit/comment.js +0 -1
- package/clis/reddit/frontpage.js +0 -1
- package/clis/reddit/home.js +0 -1
- package/clis/reddit/popular.js +0 -1
- package/clis/reddit/read.js +0 -1
- package/clis/reddit/read.test.js +2 -2
- package/clis/reddit/save.js +0 -1
- package/clis/reddit/saved.js +0 -1
- package/clis/reddit/search.js +0 -1
- package/clis/reddit/subreddit-info.js +0 -1
- package/clis/reddit/subreddit.js +0 -1
- package/clis/reddit/subscribe.js +0 -1
- package/clis/reddit/upvote.js +0 -1
- package/clis/reddit/upvoted.js +0 -1
- package/clis/reddit/user-comments.js +0 -1
- package/clis/reddit/user-posts.js +0 -1
- package/clis/reddit/user.js +0 -1
- package/clis/reddit/whoami.js +0 -1
- package/clis/rednote/rednote.test.js +65 -0
- package/clis/rednote/search.js +11 -5
- package/clis/twitter/article.js +0 -1
- package/clis/twitter/bookmark-folder.js +5 -4
- package/clis/twitter/bookmark-folder.test.js +59 -1
- package/clis/twitter/bookmark-folders.js +0 -1
- package/clis/twitter/bookmarks.js +9 -4
- package/clis/twitter/bookmarks.test.js +205 -0
- package/clis/twitter/download.js +0 -1
- package/clis/twitter/followers.js +0 -1
- package/clis/twitter/following.js +0 -1
- package/clis/twitter/likes.js +0 -1
- package/clis/twitter/list-tweets.js +0 -1
- package/clis/twitter/lists.js +0 -1
- package/clis/twitter/notifications.js +0 -1
- package/clis/twitter/profile.js +0 -1
- package/clis/twitter/search.js +0 -1
- package/clis/twitter/thread.js +0 -1
- package/clis/twitter/timeline.js +0 -1
- package/clis/twitter/trending.js +0 -1
- package/clis/twitter/tweets.js +0 -1
- package/clis/xiaohongshu/search.js +34 -16
- package/clis/xiaohongshu/search.test.js +66 -11
- package/clis/yahoo/search.js +92 -0
- package/clis/yahoo/search.test.js +94 -0
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +3 -0
- package/dist/src/browser/daemon-client.test.js +20 -0
- package/dist/src/cli.js +8 -3
- package/dist/src/cli.test.js +1 -0
- package/dist/src/daemon-utils.d.ts +18 -0
- package/dist/src/daemon-utils.js +37 -0
- package/dist/src/daemon.d.ts +1 -1
- package/dist/src/daemon.js +44 -13
- package/dist/src/daemon.test.js +42 -1
- package/dist/src/electron-apps.js +0 -1
- package/dist/src/electron-apps.test.js +1 -0
- package/dist/src/external-clis.yaml +12 -3
- package/dist/src/external.d.ts +4 -0
- package/dist/src/external.js +3 -0
- package/dist/src/external.test.js +24 -1
- package/dist/src/help.d.ts +5 -1
- package/dist/src/help.js +4 -3
- package/dist/src/help.test.js +5 -1
- package/package.json +1 -1
- package/clis/notion/export.js +0 -32
- package/clis/notion/favorites.js +0 -85
- package/clis/notion/new.js +0 -35
- package/clis/notion/read.js +0 -31
- package/clis/notion/search.js +0 -47
- package/clis/notion/sidebar.js +0 -42
- package/clis/notion/status.js +0 -17
- package/clis/notion/write.js +0 -41
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { __test__ } = await import('./search.js');
|
|
4
|
+
const command = __test__.command;
|
|
5
|
+
|
|
6
|
+
function createPageMock(evaluateResult = []) {
|
|
7
|
+
return {
|
|
8
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
9
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('brave search', () => {
|
|
15
|
+
it('should register as a valid command', () => {
|
|
16
|
+
expect(command).toBeDefined();
|
|
17
|
+
expect(command.site).toBe('brave');
|
|
18
|
+
expect(command.name).toBe('search');
|
|
19
|
+
expect(command.access).toBe('read');
|
|
20
|
+
expect(command.browser).toBe(true);
|
|
21
|
+
expect(command.strategy).toBe('public');
|
|
22
|
+
expect(command.domain).toBe('search.brave.com');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should define keyword positional arg', () => {
|
|
26
|
+
const kwArg = command.args.find(a => a.name === 'keyword');
|
|
27
|
+
expect(kwArg).toBeDefined();
|
|
28
|
+
expect(kwArg.positional).toBe(true);
|
|
29
|
+
expect(kwArg.required).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should define limit arg with default 10', () => {
|
|
33
|
+
const limitArg = command.args.find(a => a.name === 'limit');
|
|
34
|
+
expect(limitArg).toBeDefined();
|
|
35
|
+
expect(limitArg.type).toBe('int');
|
|
36
|
+
expect(limitArg.default).toBe(10);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should define output columns', () => {
|
|
40
|
+
expect(command.columns).toContain('rank');
|
|
41
|
+
expect(command.columns).toContain('title');
|
|
42
|
+
expect(command.columns).toContain('url');
|
|
43
|
+
expect(command.columns).toContain('snippet');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects empty query, invalid limit, and invalid offset before navigation', async () => {
|
|
47
|
+
const page = createPageMock();
|
|
48
|
+
await expect(command.func(page, { keyword: '', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
49
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 19 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
50
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 5, offset: -1 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
51
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('unwraps browser envelopes and returns ranked HTTPS rows', async () => {
|
|
55
|
+
const page = createPageMock({
|
|
56
|
+
session: 'site:brave',
|
|
57
|
+
data: [['OpenCLI', 'https://github.com/jackwener/OpenCLI', 'CLI browser tooling']],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 1, offset: 1 })).resolves.toEqual([{
|
|
61
|
+
rank: 19,
|
|
62
|
+
title: 'OpenCLI',
|
|
63
|
+
url: 'https://github.com/jackwener/OpenCLI',
|
|
64
|
+
snippet: 'CLI browser tooling',
|
|
65
|
+
}]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('fails typed instead of silently returning [] for malformed extraction payloads', async () => {
|
|
69
|
+
const page = createPageMock({ rows: [] });
|
|
70
|
+
|
|
71
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 1 })).rejects.toMatchObject({
|
|
72
|
+
code: 'COMMAND_EXEC',
|
|
73
|
+
message: expect.stringContaining('payload shape'),
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
3
|
+
import {
|
|
4
|
+
emptySearchResults,
|
|
5
|
+
requireBoundedInteger,
|
|
6
|
+
requireNonNegativeInteger,
|
|
7
|
+
requireRows,
|
|
8
|
+
requireSearchQuery,
|
|
9
|
+
runBrowserStep,
|
|
10
|
+
toHttpsUrl,
|
|
11
|
+
} from '../_shared/search-adapter.js';
|
|
12
|
+
|
|
13
|
+
function decodeDdgUrl(href) {
|
|
14
|
+
if (!href) return '';
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(href, 'https://duckduckgo.com');
|
|
17
|
+
const uddg = url.searchParams.get('uddg');
|
|
18
|
+
return toHttpsUrl(uddg || href, 'https://duckduckgo.com');
|
|
19
|
+
} catch {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildExtractFn(limit) {
|
|
25
|
+
return 'function(doc){' +
|
|
26
|
+
'var r=[];var seen={};var items=doc.querySelectorAll(".result");' +
|
|
27
|
+
'for(var i=0;i<items.length;i++){' +
|
|
28
|
+
'if(r.length>=' + limit + ')break;' +
|
|
29
|
+
'var el=items[i];var te=el.querySelector(".result__a");' +
|
|
30
|
+
'var se=el.querySelector(".result__snippet");' +
|
|
31
|
+
'var ue=el.querySelector(".result__url");' +
|
|
32
|
+
'var ie=el.querySelector(".result__icon__img");' +
|
|
33
|
+
'var cls=el.className||"";var rt="web";' +
|
|
34
|
+
'if(cls.indexOf("result--ad")!==-1||cls.indexOf("result--ads")!==-1||cls.indexOf("badge--ad")!==-1)continue;' +
|
|
35
|
+
'if(!te)continue;' +
|
|
36
|
+
'var t=(te.textContent||"").trim();' +
|
|
37
|
+
'var h=te.getAttribute("href")||"";' +
|
|
38
|
+
'var sn=se?(se.textContent||"").trim():"";' +
|
|
39
|
+
'var du=ue?(ue.textContent||"").trim():"";' +
|
|
40
|
+
'var ic=ie?(ie.getAttribute("src")||""):"";' +
|
|
41
|
+
'if(cls.indexOf("news-result")!==-1)rt="news";' +
|
|
42
|
+
'else if(cls.indexOf("video-result")!==-1)rt="video";' +
|
|
43
|
+
'else if(cls.indexOf("image-result")!==-1)rt="image";' +
|
|
44
|
+
'if(!t||!h||seen[h])continue;seen[h]=true;' +
|
|
45
|
+
'r.push([t,h,sn,du,ic,rt]);' +
|
|
46
|
+
'}return r;}';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildExtractorJs(limit) {
|
|
50
|
+
return '(' + buildExtractFn(limit) + '(document))';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildPaginateJs(limit, keyword, offset, region) {
|
|
54
|
+
var params = 'q=' + encodeURIComponent(keyword) + '&s=' + offset + '&v=l&o=json';
|
|
55
|
+
if (region) params += '&kl=' + encodeURIComponent(region);
|
|
56
|
+
return (
|
|
57
|
+
'new Promise(function($r){' +
|
|
58
|
+
'var x=new XMLHttpRequest();' +
|
|
59
|
+
'x.open("POST","/html/",true);' +
|
|
60
|
+
'x.setRequestHeader("Content-Type","application/x-www-form-urlencoded");' +
|
|
61
|
+
'x.onload=function(){' +
|
|
62
|
+
'try{var d=new DOMParser().parseFromString(x.responseText,"text/html");' +
|
|
63
|
+
'$r(' + buildExtractFn(limit) + '(d));' +
|
|
64
|
+
'}catch(e){$r({error:"parse",message:String(e&&e.message||e)})}' +
|
|
65
|
+
'};' +
|
|
66
|
+
'x.onerror=function(){$r({error:"network"})};' +
|
|
67
|
+
'x.send("' + params + '");' +
|
|
68
|
+
'})'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const command = cli({
|
|
73
|
+
site: 'duckduckgo',
|
|
74
|
+
name: 'search',
|
|
75
|
+
access: 'read',
|
|
76
|
+
description: 'Search DuckDuckGo',
|
|
77
|
+
domain: 'html.duckduckgo.com',
|
|
78
|
+
strategy: Strategy.PUBLIC,
|
|
79
|
+
browser: true,
|
|
80
|
+
args: [
|
|
81
|
+
{ name: 'keyword', positional: true, required: true, help: 'Search query' },
|
|
82
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Number of results per page (1-10). For multi-page, use --offset' },
|
|
83
|
+
{ name: 'offset', type: 'int', default: 0, help: 'Result offset for pagination (0, 10, 20...). Uses XHR POST internally' },
|
|
84
|
+
{ name: 'region', help: 'Region code (e.g. jp-jp, us-en, cn-zh). Default: all regions' },
|
|
85
|
+
{ name: 'time', help: 'Time range: d (day), w (week), m (month), y (year)' },
|
|
86
|
+
],
|
|
87
|
+
columns: ['rank', 'title', 'url', 'snippet', 'displayUrl', 'icon', 'resultType'],
|
|
88
|
+
func: async (page, kwargs) => {
|
|
89
|
+
const limit = requireBoundedInteger(kwargs.limit, 10, 1, 10, '--limit');
|
|
90
|
+
const keyword = requireSearchQuery(kwargs.keyword);
|
|
91
|
+
const offset = requireNonNegativeInteger(kwargs.offset, 0, '--offset');
|
|
92
|
+
if (offset % 10 !== 0) {
|
|
93
|
+
throw new ArgumentError('--offset must be a multiple of 10 for DuckDuckGo HTML pagination');
|
|
94
|
+
}
|
|
95
|
+
if (kwargs.time && !/^(d|w|m|y)$/.test(String(kwargs.time))) {
|
|
96
|
+
throw new ArgumentError('--time must be one of d, w, m, or y');
|
|
97
|
+
}
|
|
98
|
+
let url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(keyword)}`;
|
|
99
|
+
if (kwargs.region) url += `&kl=${encodeURIComponent(String(kwargs.region))}`;
|
|
100
|
+
if (kwargs.time) url += `&df=${encodeURIComponent(String(kwargs.time))}`;
|
|
101
|
+
await runBrowserStep('duckduckgo search navigation', () => page.goto(url));
|
|
102
|
+
try {
|
|
103
|
+
await page.wait({ selector: '.result', timeout: 8 });
|
|
104
|
+
} catch {
|
|
105
|
+
await page.wait(3).catch(function() {});
|
|
106
|
+
}
|
|
107
|
+
var raw;
|
|
108
|
+
if (offset === 0) {
|
|
109
|
+
raw = await runBrowserStep('duckduckgo search extraction', () => page.evaluate(buildExtractorJs(limit)));
|
|
110
|
+
} else {
|
|
111
|
+
raw = await runBrowserStep('duckduckgo search pagination extraction', () => page.evaluate(buildPaginateJs(limit, keyword, offset, kwargs.region)));
|
|
112
|
+
}
|
|
113
|
+
const rows = requireRows(raw, 'duckduckgo search');
|
|
114
|
+
if (rows.length === 0) {
|
|
115
|
+
throw emptySearchResults('DuckDuckGo', keyword);
|
|
116
|
+
}
|
|
117
|
+
return rows.map(function(r, index) {
|
|
118
|
+
return {
|
|
119
|
+
rank: index + 1 + offset,
|
|
120
|
+
title: r[0],
|
|
121
|
+
url: decodeDdgUrl(r[1]),
|
|
122
|
+
snippet: r[2],
|
|
123
|
+
displayUrl: r[3],
|
|
124
|
+
icon: r[4],
|
|
125
|
+
resultType: r[5],
|
|
126
|
+
};
|
|
127
|
+
}).filter((row) => row.url);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export const __test__ = { command };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
|
|
4
|
+
const { __test__ } = await import('./search.js');
|
|
5
|
+
const command = __test__.command;
|
|
6
|
+
|
|
7
|
+
function createPageMock(evaluateResult = []) {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('duckduckgo search', () => {
|
|
16
|
+
it('should register as a valid command', () => {
|
|
17
|
+
expect(command).toBeDefined();
|
|
18
|
+
expect(command.site).toBe('duckduckgo');
|
|
19
|
+
expect(command.name).toBe('search');
|
|
20
|
+
expect(command.access).toBe('read');
|
|
21
|
+
expect(command.browser).toBe(true);
|
|
22
|
+
expect(command.strategy).toBe('public');
|
|
23
|
+
expect(command.domain).toBe('html.duckduckgo.com');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should define keyword positional arg', () => {
|
|
27
|
+
const kwArg = command.args.find(a => a.name === 'keyword');
|
|
28
|
+
expect(kwArg).toBeDefined();
|
|
29
|
+
expect(kwArg.positional).toBe(true);
|
|
30
|
+
expect(kwArg.required).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should define limit arg with default 10', () => {
|
|
34
|
+
const limitArg = command.args.find(a => a.name === 'limit');
|
|
35
|
+
expect(limitArg).toBeDefined();
|
|
36
|
+
expect(limitArg.type).toBe('int');
|
|
37
|
+
expect(limitArg.default).toBe(10);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should define columns for output', () => {
|
|
41
|
+
expect(command.columns).toContain('rank');
|
|
42
|
+
expect(command.columns).toContain('title');
|
|
43
|
+
expect(command.columns).toContain('url');
|
|
44
|
+
expect(command.columns).toContain('snippet');
|
|
45
|
+
expect(command.columns).toContain('displayUrl');
|
|
46
|
+
expect(command.columns).toContain('icon');
|
|
47
|
+
expect(command.columns).toContain('resultType');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects empty query and out-of-range pagination before navigation', async () => {
|
|
51
|
+
const page = createPageMock();
|
|
52
|
+
await expect(command.func(page, { keyword: ' ', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
53
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 11 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
54
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 5, offset: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
55
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('decodes DuckDuckGo redirect URLs and assigns listing rank', async () => {
|
|
59
|
+
const page = createPageMock([
|
|
60
|
+
[
|
|
61
|
+
'OpenCLI',
|
|
62
|
+
'/l/?uddg=https%3A%2F%2Fgithub.com%2Fjackwener%2FOpenCLI',
|
|
63
|
+
'CLI browser tooling',
|
|
64
|
+
'github.com/jackwener/OpenCLI',
|
|
65
|
+
'',
|
|
66
|
+
'web',
|
|
67
|
+
],
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 1 })).resolves.toEqual([{
|
|
71
|
+
rank: 1,
|
|
72
|
+
title: 'OpenCLI',
|
|
73
|
+
url: 'https://github.com/jackwener/OpenCLI',
|
|
74
|
+
snippet: 'CLI browser tooling',
|
|
75
|
+
displayUrl: 'github.com/jackwener/OpenCLI',
|
|
76
|
+
icon: '',
|
|
77
|
+
resultType: 'web',
|
|
78
|
+
}]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('executes the DOM extractor, filters ads, and returns canonical rows', async () => {
|
|
82
|
+
const dom = new JSDOM(`
|
|
83
|
+
<div class="result result--ad">
|
|
84
|
+
<a class="result__a" href="https://ads.example/">Sponsored result</a>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="result">
|
|
87
|
+
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Organic result</a>
|
|
88
|
+
<a class="result__snippet">Organic snippet</a>
|
|
89
|
+
<span class="result__url">example.com/article</span>
|
|
90
|
+
<img class="result__icon__img" src="https://icons.duckduckgo.com/ip3/example.com.ico">
|
|
91
|
+
</div>
|
|
92
|
+
`);
|
|
93
|
+
const page = {
|
|
94
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
95
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
96
|
+
evaluate: vi.fn(async (source) => Function('document', `return ${source};`)(dom.window.document)),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 5 })).resolves.toEqual([{
|
|
100
|
+
rank: 1,
|
|
101
|
+
title: 'Organic result',
|
|
102
|
+
url: 'https://example.com/article',
|
|
103
|
+
snippet: 'Organic snippet',
|
|
104
|
+
displayUrl: 'example.com/article',
|
|
105
|
+
icon: 'https://icons.duckduckgo.com/ip3/example.com.ico',
|
|
106
|
+
resultType: 'web',
|
|
107
|
+
}]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('unwraps browser envelopes for paginated extraction', async () => {
|
|
111
|
+
const page = createPageMock({ session: 'site:duckduckgo', data: [
|
|
112
|
+
['Result', 'https://example.com/', 'snippet', 'example.com', '', 'web'],
|
|
113
|
+
] });
|
|
114
|
+
|
|
115
|
+
const result = await command.func(page, { keyword: 'opencli', limit: 1, offset: 10 });
|
|
116
|
+
|
|
117
|
+
expect(result[0]).toMatchObject({ rank: 11, url: 'https://example.com/' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('fails typed instead of returning [] for malformed extraction payloads', async () => {
|
|
121
|
+
const page = createPageMock({ rows: [] });
|
|
122
|
+
|
|
123
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 1 })).rejects.toMatchObject({
|
|
124
|
+
code: 'COMMAND_EXEC',
|
|
125
|
+
message: expect.stringContaining('payload shape'),
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { requireBoundedInteger, requireSearchQuery } from '../_shared/search-adapter.js';
|
|
4
|
+
|
|
5
|
+
const command = cli({
|
|
6
|
+
site: 'duckduckgo',
|
|
7
|
+
name: 'suggest',
|
|
8
|
+
access: 'read',
|
|
9
|
+
description: 'DuckDuckGo search suggestions',
|
|
10
|
+
domain: 'duckduckgo.com',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
browser: false,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'keyword', positional: true, required: true, help: 'Search query prefix' },
|
|
15
|
+
{ name: 'limit', type: 'int', default: 8, help: 'Max number of suggestions' },
|
|
16
|
+
],
|
|
17
|
+
columns: ['phrase'],
|
|
18
|
+
func: async (kwargs) => {
|
|
19
|
+
const limit = requireBoundedInteger(kwargs.limit, 8, 1, 20, '--limit');
|
|
20
|
+
const keyword = encodeURIComponent(requireSearchQuery(kwargs.keyword));
|
|
21
|
+
const url = `https://duckduckgo.com/ac/?q=${keyword}&type=list`;
|
|
22
|
+
let resp;
|
|
23
|
+
try {
|
|
24
|
+
resp = await fetch(url);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
throw new CommandExecutionError(`DuckDuckGo suggest request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
27
|
+
}
|
|
28
|
+
if (!resp.ok) {
|
|
29
|
+
throw new CommandExecutionError(`DuckDuckGo suggest returned HTTP ${resp.status}`);
|
|
30
|
+
}
|
|
31
|
+
let data;
|
|
32
|
+
try {
|
|
33
|
+
data = await resp.json();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new CommandExecutionError(`DuckDuckGo suggest returned malformed JSON: ${err?.message ?? err}`);
|
|
36
|
+
}
|
|
37
|
+
const phrases = Array.isArray(data) && data.length > 1 && Array.isArray(data[1]) ? data[1] : [];
|
|
38
|
+
return phrases
|
|
39
|
+
.filter((phrase) => typeof phrase === 'string' && phrase.trim())
|
|
40
|
+
.slice(0, limit)
|
|
41
|
+
.map(function(p) { return { phrase: p }; });
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const __test__ = { command };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { __test__ } = await import('./suggest.js');
|
|
4
|
+
const command = __test__.command;
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.restoreAllMocks();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('duckduckgo suggest', () => {
|
|
11
|
+
it('should register as a valid command', () => {
|
|
12
|
+
expect(command).toBeDefined();
|
|
13
|
+
expect(command.site).toBe('duckduckgo');
|
|
14
|
+
expect(command.name).toBe('suggest');
|
|
15
|
+
expect(command.access).toBe('read');
|
|
16
|
+
expect(command.browser).toBe(false);
|
|
17
|
+
expect(command.strategy).toBe('public');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should define keyword positional arg', () => {
|
|
21
|
+
const kwArg = command.args.find(a => a.name === 'keyword');
|
|
22
|
+
expect(kwArg).toBeDefined();
|
|
23
|
+
expect(kwArg.positional).toBe(true);
|
|
24
|
+
expect(kwArg.required).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should define limit arg with default 8', () => {
|
|
28
|
+
const limitArg = command.args.find(a => a.name === 'limit');
|
|
29
|
+
expect(limitArg).toBeDefined();
|
|
30
|
+
expect(limitArg.default).toBe(8);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should define phrase column', () => {
|
|
34
|
+
expect(command.columns).toEqual(['phrase']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('rejects empty query and invalid limit before fetch', async () => {
|
|
38
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
39
|
+
await expect(command.func({ keyword: '', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
40
|
+
await expect(command.func({ keyword: 'opencli', limit: 21 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
41
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns filtered suggestion rows from the public API payload', async () => {
|
|
45
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
46
|
+
ok: true,
|
|
47
|
+
json: async () => ['open', ['opencli', '', 'open source']],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await expect(command.func({ keyword: 'open', limit: 3 })).resolves.toEqual([
|
|
51
|
+
{ phrase: 'opencli' },
|
|
52
|
+
{ phrase: 'open source' },
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('maps fetch and malformed JSON failures to typed command errors', async () => {
|
|
57
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('offline'));
|
|
58
|
+
await expect(command.func({ keyword: 'open', limit: 3 })).rejects.toMatchObject({ code: 'COMMAND_EXEC' });
|
|
59
|
+
|
|
60
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
|
61
|
+
ok: true,
|
|
62
|
+
json: async () => { throw new Error('bad json'); },
|
|
63
|
+
});
|
|
64
|
+
await expect(command.func({ keyword: 'open', limit: 3 })).rejects.toMatchObject({ code: 'COMMAND_EXEC' });
|
|
65
|
+
});
|
|
66
|
+
});
|