@jackwener/opencli 1.7.17 → 1.7.19

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.
Files changed (118) hide show
  1. package/README.md +10 -8
  2. package/README.zh-CN.md +9 -8
  3. package/cli-manifest.json +585 -9
  4. package/clis/ctrip/ctrip.test.js +486 -1
  5. package/clis/ctrip/flight.js +136 -0
  6. package/clis/ctrip/hotel-search.js +132 -0
  7. package/clis/ctrip/utils.js +298 -0
  8. package/clis/doubao/utils.js +17 -0
  9. package/clis/doubao/utils.test.js +61 -0
  10. package/clis/google/search.js +16 -6
  11. package/clis/google-scholar/search.js +20 -5
  12. package/clis/google-scholar/search.test.js +35 -2
  13. package/clis/reddit/home.js +117 -0
  14. package/clis/reddit/home.test.js +127 -0
  15. package/clis/reddit/read.js +400 -54
  16. package/clis/reddit/read.test.js +315 -12
  17. package/clis/reddit/reply.js +182 -0
  18. package/clis/reddit/reply.test.js +89 -0
  19. package/clis/reddit/subreddit-info.js +117 -0
  20. package/clis/reddit/subreddit-info.test.js +163 -0
  21. package/clis/reddit/whoami.js +84 -0
  22. package/clis/reddit/whoami.test.js +105 -0
  23. package/clis/rednote/comments.js +76 -0
  24. package/clis/rednote/download.js +59 -0
  25. package/clis/rednote/feed.js +95 -0
  26. package/clis/rednote/navigation.test.js +26 -0
  27. package/clis/rednote/note.js +68 -0
  28. package/clis/rednote/notifications.js +139 -0
  29. package/clis/rednote/rednote.test.js +157 -0
  30. package/clis/rednote/search.js +101 -0
  31. package/clis/rednote/user.js +55 -0
  32. package/clis/twitter/bookmark-folder.js +3 -1
  33. package/clis/twitter/bookmarks.js +3 -1
  34. package/clis/twitter/followers.js +20 -5
  35. package/clis/twitter/followers.test.js +44 -0
  36. package/clis/twitter/following.js +36 -20
  37. package/clis/twitter/following.test.js +60 -8
  38. package/clis/twitter/likes.js +28 -13
  39. package/clis/twitter/likes.test.js +111 -1
  40. package/clis/twitter/list-add.js +128 -204
  41. package/clis/twitter/list-add.test.js +97 -1
  42. package/clis/twitter/list-tweets.js +13 -4
  43. package/clis/twitter/list-tweets.test.js +48 -0
  44. package/clis/twitter/lists.js +5 -2
  45. package/clis/twitter/post.js +23 -4
  46. package/clis/twitter/post.test.js +30 -0
  47. package/clis/twitter/profile.js +16 -8
  48. package/clis/twitter/profile.test.js +39 -0
  49. package/clis/twitter/reply.js +133 -10
  50. package/clis/twitter/reply.test.js +55 -0
  51. package/clis/twitter/search.js +188 -170
  52. package/clis/twitter/search.test.js +96 -258
  53. package/clis/twitter/shared.js +167 -16
  54. package/clis/twitter/shared.test.js +102 -1
  55. package/clis/twitter/timeline.js +3 -1
  56. package/clis/twitter/tweets.js +147 -51
  57. package/clis/twitter/tweets.test.js +238 -1
  58. package/clis/xiaohongshu/comments.js +57 -26
  59. package/clis/xiaohongshu/comments.test.js +63 -1
  60. package/clis/xiaohongshu/download.js +32 -23
  61. package/clis/xiaohongshu/feed.js +23 -15
  62. package/clis/xiaohongshu/note-helpers.js +16 -6
  63. package/clis/xiaohongshu/note.js +26 -20
  64. package/clis/xiaohongshu/notifications.js +26 -19
  65. package/clis/xiaohongshu/search.js +201 -37
  66. package/clis/xiaohongshu/search.test.js +82 -8
  67. package/clis/xiaohongshu/user-helpers.js +13 -4
  68. package/clis/xiaohongshu/user-helpers.test.js +20 -0
  69. package/clis/xiaohongshu/user.js +9 -4
  70. package/clis/xueqiu/earnings-date.js +2 -2
  71. package/clis/xueqiu/kline.js +2 -2
  72. package/clis/xueqiu/utils.js +19 -0
  73. package/clis/xueqiu/utils.test.js +26 -0
  74. package/clis/youtube/transcript.js +28 -3
  75. package/clis/youtube/transcript.test.js +90 -1
  76. package/clis/zhihu/answer-detail.js +233 -0
  77. package/clis/zhihu/answer-detail.test.js +330 -0
  78. package/clis/zhihu/question.js +44 -10
  79. package/clis/zhihu/question.test.js +78 -1
  80. package/clis/zhihu/recommend.js +103 -0
  81. package/clis/zhihu/recommend.test.js +143 -0
  82. package/dist/src/browser/base-page.d.ts +3 -2
  83. package/dist/src/browser/base-page.test.js +2 -2
  84. package/dist/src/browser/cdp.js +3 -3
  85. package/dist/src/browser/page.d.ts +3 -2
  86. package/dist/src/browser/page.js +4 -4
  87. package/dist/src/browser/page.test.js +31 -0
  88. package/dist/src/browser/utils.d.ts +10 -0
  89. package/dist/src/browser/utils.js +37 -0
  90. package/dist/src/browser/utils.test.d.ts +1 -0
  91. package/dist/src/browser/utils.test.js +29 -0
  92. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  93. package/dist/src/cli-argv-preprocess.js +131 -0
  94. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  95. package/dist/src/cli-argv-preprocess.test.js +130 -0
  96. package/dist/src/cli.js +123 -86
  97. package/dist/src/cli.test.js +32 -22
  98. package/dist/src/commands/daemon.js +6 -7
  99. package/dist/src/doctor.js +21 -17
  100. package/dist/src/doctor.test.js +2 -0
  101. package/dist/src/download/progress.js +15 -11
  102. package/dist/src/download/progress.test.d.ts +1 -0
  103. package/dist/src/download/progress.test.js +25 -0
  104. package/dist/src/execution.js +1 -3
  105. package/dist/src/execution.test.js +4 -16
  106. package/dist/src/help.d.ts +11 -0
  107. package/dist/src/help.js +46 -5
  108. package/dist/src/logger.js +8 -9
  109. package/dist/src/main.js +16 -0
  110. package/dist/src/output.js +4 -5
  111. package/dist/src/runtime-detect.d.ts +1 -1
  112. package/dist/src/runtime-detect.js +1 -1
  113. package/dist/src/runtime-detect.test.js +3 -2
  114. package/dist/src/tui.d.ts +0 -1
  115. package/dist/src/tui.js +9 -22
  116. package/dist/src/types.d.ts +3 -1
  117. package/dist/src/update-check.js +4 -5
  118. package/package.json +5 -4
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
3
  import { getRegistry } from '@jackwener/opencli/registry';
3
4
  import './search.js';
4
5
 
@@ -25,14 +26,46 @@ describe('google-scholar search command', () => {
25
26
  const page = {
26
27
  goto: vi.fn().mockResolvedValue(undefined),
27
28
  wait: vi.fn().mockResolvedValue(undefined),
28
- evaluate: vi.fn().mockResolvedValue([]),
29
+ evaluate: vi.fn().mockResolvedValue({ items: [{ rank: 1, title: 'Paper' }], resultCount: 1 }),
29
30
  };
30
31
 
31
- await command.func(page, { query: 'transformer' });
32
+ const rows = await command.func(page, { query: 'transformer' });
32
33
 
33
34
  const script = page.evaluate.mock.calls[0][0];
34
35
  expect(script).toContain("document.querySelectorAll('.gs_r.gs_or.gs_scl')");
35
36
  expect(script).not.toContain(".gs_r.gs_or.gs_scl, .gs_ri");
36
37
  expect(script).toContain("const container = el.querySelector('.gs_ri') || el");
38
+ expect(script).toContain('return { items: results, resultCount: resultCards.length }');
39
+ expect(rows).toEqual([{ rank: 1, title: 'Paper' }]);
40
+ });
41
+
42
+ it('throws typed empty when Scholar returns no result cards', async () => {
43
+ const page = {
44
+ goto: vi.fn().mockResolvedValue(undefined),
45
+ wait: vi.fn().mockResolvedValue(undefined),
46
+ evaluate: vi.fn().mockResolvedValue({ items: [], resultCount: 0 }),
47
+ };
48
+
49
+ await expect(command.func(page, { query: 'no results expected' })).rejects.toThrow(EmptyResultError);
50
+ });
51
+
52
+ it('throws command execution when result cards exist but parser extracts no rows', async () => {
53
+ const page = {
54
+ goto: vi.fn().mockResolvedValue(undefined),
55
+ wait: vi.fn().mockResolvedValue(undefined),
56
+ evaluate: vi.fn().mockResolvedValue({ items: [], resultCount: 2 }),
57
+ };
58
+
59
+ await expect(command.func(page, { query: 'parser drift' })).rejects.toThrow(CommandExecutionError);
60
+ });
61
+
62
+ it('throws command execution for malformed evaluate payloads instead of treating them as empty', async () => {
63
+ const page = {
64
+ goto: vi.fn().mockResolvedValue(undefined),
65
+ wait: vi.fn().mockResolvedValue(undefined),
66
+ evaluate: vi.fn().mockResolvedValue({ items: { rank: 1 }, resultCount: 1 }),
67
+ };
68
+
69
+ await expect(command.func(page, { query: 'bad payload' })).rejects.toThrow(CommandExecutionError);
37
70
  });
38
71
  });
@@ -0,0 +1,117 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+
4
+ const REDDIT_HOME_MAX_LIMIT = 100;
5
+
6
+ export function parseRedditHomeLimit(raw) {
7
+ if (raw === undefined || raw === null || raw === '') return 25;
8
+ const n = Number(raw);
9
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > REDDIT_HOME_MAX_LIMIT) {
10
+ throw new ArgumentError(
11
+ `limit must be an integer in [1, ${REDDIT_HOME_MAX_LIMIT}].`,
12
+ `Got: ${raw}`,
13
+ );
14
+ }
15
+ return n;
16
+ }
17
+
18
+ cli({
19
+ site: 'reddit',
20
+ name: 'home',
21
+ access: 'read',
22
+ description: 'Reddit personalized home feed (Best, requires login)',
23
+ domain: 'reddit.com',
24
+ strategy: Strategy.COOKIE,
25
+ browser: true,
26
+ siteSession: 'persistent',
27
+ args: [
28
+ { name: 'limit', type: 'int', default: 25, help: `Number of posts (1–${REDDIT_HOME_MAX_LIMIT})` },
29
+ ],
30
+ columns: ['rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url'],
31
+ func: async (page, kwargs) => {
32
+ const limit = parseRedditHomeLimit(kwargs.limit);
33
+ await page.goto('https://www.reddit.com');
34
+ // The Best feed is personalized only when logged in — for anonymous
35
+ // sessions Reddit returns a generic listing that overlaps with /r/all.
36
+ // To make `home` semantically meaningful (vs the public `frontpage`
37
+ // command) we require auth and surface the logged-out case via
38
+ // AuthRequiredError instead of silently returning the public feed.
39
+ //
40
+ // Two-pronged auth detection: HTTP 401/403 OR `me.data.name` missing
41
+ // on 200 (stale anonymous cookie session). See PR #1428 sediment.
42
+ //
43
+ // Intermediate object keys avoid `rank`/`title`/`subreddit`/etc. to
44
+ // sidestep the silent-column-drop audit; we use `entries` for the
45
+ // raw payload. See PR #1329 sediment "中间解析对象 key 不能跟
46
+ // columns 任一项重叠".
47
+ const result = await page.evaluate(`(async () => {
48
+ try {
49
+ const meRes = await fetch('/api/me.json', { credentials: 'include' });
50
+ if (meRes.status === 401 || meRes.status === 403) {
51
+ return { kind: 'auth', detail: 'Reddit /api/me.json returned HTTP ' + meRes.status };
52
+ }
53
+ if (!meRes.ok) {
54
+ return { kind: 'http', httpStatus: meRes.status, where: '/api/me.json' };
55
+ }
56
+ const me = await meRes.json();
57
+ if (!me?.data?.name) {
58
+ return { kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' };
59
+ }
60
+
61
+ const limit = ${JSON.stringify(limit)};
62
+ const res = await fetch('/best.json?limit=' + limit + '&raw_json=1', { credentials: 'include' });
63
+ if (res.status === 401 || res.status === 403) {
64
+ return { kind: 'auth', detail: 'Reddit /best.json returned HTTP ' + res.status };
65
+ }
66
+ if (!res.ok) {
67
+ return { kind: 'http', httpStatus: res.status, where: '/best.json' };
68
+ }
69
+ const j = await res.json();
70
+ const entries = j?.data?.children;
71
+ if (!Array.isArray(entries)) {
72
+ return { kind: 'http', httpStatus: 200, where: '/best.json (no data.children array)' };
73
+ }
74
+ return { kind: 'ok', entries };
75
+ } catch (e) {
76
+ return { kind: 'exception', detail: String(e && e.message || e) };
77
+ }
78
+ })()`);
79
+
80
+ if (result?.kind === 'auth') {
81
+ throw new AuthRequiredError('reddit.com', result.detail);
82
+ }
83
+ if (result?.kind === 'http') {
84
+ throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
85
+ }
86
+ if (result?.kind === 'exception') {
87
+ throw new CommandExecutionError(`home failed: ${result.detail}`);
88
+ }
89
+ if (result?.kind !== 'ok') {
90
+ throw new CommandExecutionError(`Unexpected result from reddit home: ${JSON.stringify(result)}`);
91
+ }
92
+
93
+ const rows = [];
94
+ const entries = result.entries.slice(0, limit);
95
+ for (let i = 0; i < entries.length; i++) {
96
+ const d = entries[i]?.data;
97
+ if (!d || !d.id) continue;
98
+ rows.push({
99
+ rank: i + 1,
100
+ title: typeof d.title === 'string' ? d.title : null,
101
+ subreddit: typeof d.subreddit_name_prefixed === 'string' ? d.subreddit_name_prefixed : null,
102
+ score: typeof d.score === 'number' ? d.score : null,
103
+ comments: typeof d.num_comments === 'number' ? d.num_comments : null,
104
+ postId: d.id,
105
+ author: typeof d.author === 'string' ? d.author : null,
106
+ url: d.permalink ? 'https://www.reddit.com' + d.permalink : null,
107
+ });
108
+ }
109
+ if (rows.length === 0) {
110
+ if (entries.length > 0) {
111
+ throw new CommandExecutionError('Reddit home feed entries were missing required post id anchors');
112
+ }
113
+ throw new EmptyResultError('Reddit returned no posts in the personalized home feed.');
114
+ }
115
+ return rows;
116
+ },
117
+ });
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import { parseRedditHomeLimit } from './home.js';
5
+ import './home.js';
6
+
7
+ function makePage(result) {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ evaluate: vi.fn().mockResolvedValue(result),
11
+ };
12
+ }
13
+
14
+ function makeEntry(id, overrides = {}) {
15
+ return {
16
+ data: {
17
+ id,
18
+ title: `Title for ${id}`,
19
+ subreddit_name_prefixed: 'r/dummy',
20
+ score: 100,
21
+ num_comments: 10,
22
+ author: 'someone',
23
+ permalink: `/r/dummy/comments/${id}/title/`,
24
+ ...overrides,
25
+ },
26
+ };
27
+ }
28
+
29
+ describe('reddit home command', () => {
30
+ const command = getRegistry().get('reddit/home');
31
+
32
+ it('registers with the expected shape', () => {
33
+ expect(command).toBeDefined();
34
+ expect(command.access).toBe('read');
35
+ expect(command.browser).toBe(true);
36
+ expect(command.columns).toEqual(['rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url']);
37
+ });
38
+
39
+ it('parseRedditHomeLimit accepts [1,100] and rejects out-of-range / non-integer without silent clamp', () => {
40
+ expect(parseRedditHomeLimit(undefined)).toBe(25);
41
+ expect(parseRedditHomeLimit(null)).toBe(25);
42
+ expect(parseRedditHomeLimit('')).toBe(25);
43
+ expect(parseRedditHomeLimit(1)).toBe(1);
44
+ expect(parseRedditHomeLimit(25)).toBe(25);
45
+ expect(parseRedditHomeLimit(100)).toBe(100);
46
+
47
+ for (const bad of [0, -1, 101, 1.5, NaN, 'abc']) {
48
+ expect(() => parseRedditHomeLimit(bad)).toThrow(ArgumentError);
49
+ }
50
+ });
51
+
52
+ it('rejects a bad limit BEFORE navigating', async () => {
53
+ const page = makePage({ kind: 'ok', entries: [] });
54
+ await expect(command.func(page, { limit: 101 })).rejects.toBeInstanceOf(ArgumentError);
55
+ expect(page.goto).not.toHaveBeenCalled();
56
+ expect(page.evaluate).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it('throws AuthRequiredError when logged out (401/403 or missing identity)', async () => {
60
+ await expect(command.func(makePage({ kind: 'auth', detail: 'login required' }), { limit: 25 }))
61
+ .rejects.toBeInstanceOf(AuthRequiredError);
62
+ });
63
+
64
+ it('throws CommandExecutionError on HTTP / exception failure modes', async () => {
65
+ await expect(command.func(makePage({ kind: 'http', httpStatus: 503, where: '/best.json' }), { limit: 25 }))
66
+ .rejects.toBeInstanceOf(CommandExecutionError);
67
+ await expect(command.func(makePage({ kind: 'exception', detail: 'network' }), { limit: 25 }))
68
+ .rejects.toBeInstanceOf(CommandExecutionError);
69
+ });
70
+
71
+ it('throws EmptyResultError when Reddit returns no posts', async () => {
72
+ await expect(command.func(makePage({ kind: 'ok', entries: [] }), { limit: 25 }))
73
+ .rejects.toBeInstanceOf(EmptyResultError);
74
+ });
75
+
76
+ it('maps entries to row shape with 1-based rank, full URLs, and typed numbers', async () => {
77
+ const entries = [makeEntry('a1'), makeEntry('b2', { score: 250, num_comments: 42 })];
78
+ const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 25 });
79
+
80
+ expect(rows).toEqual([
81
+ {
82
+ rank: 1, title: 'Title for a1', subreddit: 'r/dummy', score: 100, comments: 10,
83
+ postId: 'a1', author: 'someone', url: 'https://www.reddit.com/r/dummy/comments/a1/title/',
84
+ },
85
+ {
86
+ rank: 2, title: 'Title for b2', subreddit: 'r/dummy', score: 250, comments: 42,
87
+ postId: 'b2', author: 'someone', url: 'https://www.reddit.com/r/dummy/comments/b2/title/',
88
+ },
89
+ ]);
90
+ // Row shape must match declared columns exactly.
91
+ for (const row of rows) {
92
+ expect(Object.keys(row).sort()).toEqual(
93
+ ['author', 'comments', 'postId', 'rank', 'score', 'subreddit', 'title', 'url'],
94
+ );
95
+ }
96
+ });
97
+
98
+ it('applies the post-fetch limit slice (defence in depth vs Reddit overshoot)', async () => {
99
+ const entries = Array.from({ length: 30 }, (_, i) => makeEntry(`p${i}`));
100
+ const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 5 });
101
+ expect(rows).toHaveLength(5);
102
+ expect(rows[0].postId).toBe('p0');
103
+ expect(rows[4].postId).toBe('p4');
104
+ });
105
+
106
+ it('drops entries with no data.id rather than silently emitting sentinels', async () => {
107
+ const entries = [makeEntry('keep1'), { data: { title: 'no id' } }, makeEntry('keep2')];
108
+ const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 25 });
109
+ expect(rows.map((r) => r.postId)).toEqual(['keep1', 'keep2']);
110
+ });
111
+
112
+ it('throws CommandExecutionError when all home entries are missing post ids', async () => {
113
+ const entries = [{ data: { title: 'no id' } }, { data: { title: 'also no id' } }];
114
+ await expect(command.func(makePage({ kind: 'ok', entries }), { limit: 25 }))
115
+ .rejects.toMatchObject({
116
+ code: 'COMMAND_EXEC',
117
+ message: expect.stringContaining('required post id anchors'),
118
+ });
119
+ });
120
+
121
+ it('embeds the requested limit literally inside the evaluate script', async () => {
122
+ const page = makePage({ kind: 'ok', entries: [makeEntry('x')] });
123
+ await command.func(page, { limit: 7 });
124
+ const script = page.evaluate.mock.calls[0][0];
125
+ expect(script).toContain('const limit = 7');
126
+ });
127
+ });