@jackwener/opencli 1.7.3 → 1.7.4
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 +16 -16
- package/README.zh-CN.md +28 -15
- package/cli-manifest.json +547 -10
- package/clis/bilibili/favorite.js +18 -13
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -3
- package/clis/chatgpt-app/ax.js +6 -3
- package/clis/douban/search.js +1 -0
- package/clis/douban/search.test.js +11 -0
- package/clis/douban/subject.js +20 -93
- package/clis/douban/subject.test.js +11 -0
- package/clis/douban/utils.js +250 -8
- package/clis/douban/utils.test.js +179 -4
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/tdx/hot-rank.js +47 -0
- package/clis/tdx/hot-rank.test.js +59 -0
- package/clis/ths/hot-rank.js +49 -0
- package/clis/ths/hot-rank.test.js +64 -0
- package/clis/twitter/bookmarks.js +2 -1
- package/clis/uiverse/_shared.js +368 -0
- package/clis/uiverse/_shared.test.js +55 -0
- package/clis/uiverse/code.js +47 -0
- package/clis/uiverse/preview.js +71 -0
- package/clis/xiaohongshu/comments.js +2 -2
- package/clis/xiaohongshu/comments.test.js +46 -25
- package/clis/xiaohongshu/download.js +6 -7
- package/clis/xiaohongshu/download.test.js +17 -5
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +3 -5
- package/clis/xiaohongshu/note.test.js +52 -25
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +49 -0
- package/clis/xiaoyuzhou/download.test.js +125 -0
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/youtube/feed.js +120 -0
- package/clis/youtube/history.js +118 -0
- package/clis/youtube/like.js +62 -0
- package/clis/youtube/playlist.js +97 -0
- package/clis/youtube/subscribe.js +71 -0
- package/clis/youtube/subscriptions.js +57 -0
- package/clis/youtube/unlike.js +62 -0
- package/clis/youtube/unsubscribe.js +71 -0
- package/clis/youtube/utils.js +122 -0
- package/clis/youtube/utils.test.js +32 -1
- package/clis/youtube/watch-later.js +76 -0
- package/dist/src/browser/base-page.js +25 -5
- package/dist/src/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +51 -14
- package/dist/src/browser/cdp.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/dom-snapshot.js +13 -1
- package/dist/src/browser/page.d.ts +4 -1
- package/dist/src/browser/page.js +48 -8
- package/dist/src/browser/page.test.js +61 -1
- package/dist/src/browser/target-errors.d.ts +23 -0
- package/dist/src/browser/target-errors.js +29 -0
- package/dist/src/browser/target-errors.test.d.ts +1 -0
- package/dist/src/browser/target-errors.test.js +61 -0
- package/dist/src/browser/target-resolver.d.ts +57 -0
- package/dist/src/browser/target-resolver.js +298 -0
- package/dist/src/browser/target-resolver.test.d.ts +1 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +45 -37
- package/dist/src/commands/daemon.d.ts +4 -2
- package/dist/src/commands/daemon.js +22 -2
- package/dist/src/commands/daemon.test.js +65 -2
- package/dist/src/daemon.js +2 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +32 -9
- package/dist/src/doctor.test.js +28 -12
- package/dist/src/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -3
- package/dist/src/output.js +1 -5
- package/dist/src/output.test.js +0 -21
- package/dist/src/pipeline/steps/transform.js +1 -1
- package/dist/src/pipeline/template.d.ts +1 -0
- package/dist/src/pipeline/template.js +11 -3
- package/dist/src/pipeline/template.test.js +3 -0
- package/dist/src/pipeline/transform.test.js +14 -0
- package/dist/src/plugin.d.ts +7 -1
- package/dist/src/plugin.js +23 -1
- package/dist/src/plugin.test.js +15 -1
- package/dist/src/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,41 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import {
|
|
4
|
+
__test__,
|
|
5
|
+
collectDoubaoTranscriptAdditions,
|
|
6
|
+
mergeTranscriptSnapshots,
|
|
7
|
+
parseDoubaoConversationId,
|
|
8
|
+
sendDoubaoMessage,
|
|
9
|
+
waitForDoubaoResponse,
|
|
10
|
+
} from './utils.js';
|
|
11
|
+
|
|
12
|
+
function createPageMock() {
|
|
13
|
+
return {
|
|
14
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
evaluate: vi.fn(),
|
|
16
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
17
|
+
snapshot: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
click: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
typeText: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
pressKey: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
scrollTo: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
getFormState: vi.fn().mockResolvedValue({}),
|
|
23
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
25
|
+
selectTab: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
networkRequests: vi.fn().mockResolvedValue([]),
|
|
27
|
+
consoleMessages: vi.fn().mockResolvedValue([]),
|
|
28
|
+
scroll: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
32
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
screenshot: vi.fn().mockResolvedValue(''),
|
|
34
|
+
nativeType: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
nativeKeyPress: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
3
39
|
describe('parseDoubaoConversationId', () => {
|
|
4
40
|
it('extracts the numeric id from a full conversation URL', () => {
|
|
5
41
|
expect(parseDoubaoConversationId('https://www.doubao.com/chat/1234567890123')).toBe('1234567890123');
|
|
@@ -8,6 +44,209 @@ describe('parseDoubaoConversationId', () => {
|
|
|
8
44
|
expect(parseDoubaoConversationId('1234567890123')).toBe('1234567890123');
|
|
9
45
|
});
|
|
10
46
|
});
|
|
47
|
+
describe('doubao send strategy', () => {
|
|
48
|
+
it('prefers native CDP text insertion and button submission when a send button is available', async () => {
|
|
49
|
+
const page = createPageMock();
|
|
50
|
+
const evaluate = vi.mocked(page.evaluate);
|
|
51
|
+
const nativeType = vi.mocked(page.nativeType);
|
|
52
|
+
const nativeKeyPress = vi.mocked(page.nativeKeyPress);
|
|
53
|
+
evaluate
|
|
54
|
+
.mockResolvedValueOnce('https://www.doubao.com/chat')
|
|
55
|
+
.mockResolvedValueOnce({ ok: true })
|
|
56
|
+
.mockResolvedValueOnce({ hasText: true, text: '你好' })
|
|
57
|
+
.mockResolvedValueOnce({ hasText: true, text: '你好' })
|
|
58
|
+
.mockResolvedValueOnce(true)
|
|
59
|
+
.mockResolvedValueOnce({ detected: false });
|
|
60
|
+
const result = await sendDoubaoMessage(page, '你好');
|
|
61
|
+
expect(nativeType).toHaveBeenCalledWith('你好');
|
|
62
|
+
expect(nativeKeyPress).not.toHaveBeenCalled();
|
|
63
|
+
expect(result).toBe('button');
|
|
64
|
+
});
|
|
65
|
+
it('falls back to DOM insertion when native insertion does not update the composer', async () => {
|
|
66
|
+
const page = createPageMock();
|
|
67
|
+
const evaluate = vi.mocked(page.evaluate);
|
|
68
|
+
const nativeType = vi.mocked(page.nativeType);
|
|
69
|
+
evaluate
|
|
70
|
+
.mockResolvedValueOnce('https://www.doubao.com/chat')
|
|
71
|
+
.mockResolvedValueOnce({ ok: true })
|
|
72
|
+
.mockResolvedValueOnce({ hasText: false, text: '' })
|
|
73
|
+
.mockResolvedValueOnce({ hasText: false, text: '' })
|
|
74
|
+
.mockResolvedValueOnce({ hasText: true, text: '你好' })
|
|
75
|
+
.mockResolvedValueOnce(true)
|
|
76
|
+
.mockResolvedValueOnce({ detected: false });
|
|
77
|
+
const result = await sendDoubaoMessage(page, '你好');
|
|
78
|
+
expect(nativeType).toHaveBeenCalledWith('你好');
|
|
79
|
+
expect(evaluate).toHaveBeenCalledTimes(7);
|
|
80
|
+
expect(result).toBe('button');
|
|
81
|
+
});
|
|
82
|
+
it('falls back to DOM insertion when native insertion text does not match the requested prompt', async () => {
|
|
83
|
+
const page = createPageMock();
|
|
84
|
+
const evaluate = vi.mocked(page.evaluate);
|
|
85
|
+
evaluate
|
|
86
|
+
.mockResolvedValueOnce('https://www.doubao.com/chat')
|
|
87
|
+
.mockResolvedValueOnce({ ok: true })
|
|
88
|
+
.mockResolvedValueOnce({ hasText: true, text: '你' })
|
|
89
|
+
.mockResolvedValueOnce({ hasText: true, text: '你好' })
|
|
90
|
+
.mockResolvedValueOnce(true)
|
|
91
|
+
.mockResolvedValueOnce({ detected: false });
|
|
92
|
+
const result = await sendDoubaoMessage(page, '你好');
|
|
93
|
+
expect(result).toBe('button');
|
|
94
|
+
});
|
|
95
|
+
it('falls back to native Enter when no clickable submit button is found', async () => {
|
|
96
|
+
const page = createPageMock();
|
|
97
|
+
const evaluate = vi.mocked(page.evaluate);
|
|
98
|
+
const nativeKeyPress = vi.mocked(page.nativeKeyPress);
|
|
99
|
+
evaluate
|
|
100
|
+
.mockResolvedValueOnce('https://www.doubao.com/chat')
|
|
101
|
+
.mockResolvedValueOnce({ ok: true })
|
|
102
|
+
.mockResolvedValueOnce({ hasText: true, text: '你好' })
|
|
103
|
+
.mockResolvedValueOnce({ hasText: true, text: '你好' })
|
|
104
|
+
.mockResolvedValueOnce(false)
|
|
105
|
+
.mockResolvedValueOnce({ detected: false });
|
|
106
|
+
const result = await sendDoubaoMessage(page, '你好');
|
|
107
|
+
expect(nativeKeyPress).toHaveBeenCalledWith('Enter');
|
|
108
|
+
expect(result).toBe('enter');
|
|
109
|
+
});
|
|
110
|
+
it('does not throw verification errors just because the prompt mentions verification terms', async () => {
|
|
111
|
+
const page = createPageMock();
|
|
112
|
+
const evaluate = vi.mocked(page.evaluate);
|
|
113
|
+
evaluate
|
|
114
|
+
.mockResolvedValueOnce('https://www.doubao.com/chat')
|
|
115
|
+
.mockResolvedValueOnce({ ok: true })
|
|
116
|
+
.mockResolvedValueOnce({ hasText: true, text: '请解释 CAPTCHA verification 是什么' })
|
|
117
|
+
.mockResolvedValueOnce({ hasText: true, text: '请解释 CAPTCHA verification 是什么' })
|
|
118
|
+
.mockResolvedValueOnce(true)
|
|
119
|
+
.mockResolvedValueOnce({ detected: false, reason: '' });
|
|
120
|
+
await expect(sendDoubaoMessage(page, '请解释 CAPTCHA verification 是什么')).resolves.toBe('button');
|
|
121
|
+
});
|
|
122
|
+
it('does not throw verification errors for ordinary chinese prompts mentioning security terms', async () => {
|
|
123
|
+
const page = createPageMock();
|
|
124
|
+
const evaluate = vi.mocked(page.evaluate);
|
|
125
|
+
evaluate
|
|
126
|
+
.mockResolvedValueOnce('https://www.doubao.com/chat')
|
|
127
|
+
.mockResolvedValueOnce({ ok: true })
|
|
128
|
+
.mockResolvedValueOnce({ hasText: true, text: '请解释人机验证和完成安全验证的区别' })
|
|
129
|
+
.mockResolvedValueOnce({ hasText: true, text: '请解释人机验证和完成安全验证的区别' })
|
|
130
|
+
.mockResolvedValueOnce(true)
|
|
131
|
+
.mockResolvedValueOnce({ detected: false, reason: '' });
|
|
132
|
+
await expect(sendDoubaoMessage(page, '请解释人机验证和完成安全验证的区别')).resolves.toBe('button');
|
|
133
|
+
});
|
|
134
|
+
it('throws a command error when Doubao shows a verification challenge after submit', async () => {
|
|
135
|
+
const page = createPageMock();
|
|
136
|
+
const evaluate = vi.mocked(page.evaluate);
|
|
137
|
+
evaluate
|
|
138
|
+
.mockResolvedValueOnce('https://www.doubao.com/chat')
|
|
139
|
+
.mockResolvedValueOnce({ ok: true })
|
|
140
|
+
.mockResolvedValueOnce({ hasText: true, text: '你好' })
|
|
141
|
+
.mockResolvedValueOnce({ hasText: true, text: '你好' })
|
|
142
|
+
.mockResolvedValueOnce(true)
|
|
143
|
+
.mockResolvedValueOnce({ detected: true, reason: '请完成安全验证' });
|
|
144
|
+
await expect(sendDoubaoMessage(page, '你好')).rejects.toBeInstanceOf(CommandExecutionError);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe('collectDoubaoTranscriptAdditions', () => {
|
|
148
|
+
it('ignores landing-page capability chips that are not assistant content', () => {
|
|
149
|
+
const before = ['older'];
|
|
150
|
+
const current = [
|
|
151
|
+
'older',
|
|
152
|
+
'测试一下,只回复OK快速视频生成深入研究图像生成帮我写作音乐生成更多',
|
|
153
|
+
'测试一下,只回复OK',
|
|
154
|
+
];
|
|
155
|
+
expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK')).toBe('');
|
|
156
|
+
});
|
|
157
|
+
it('filters prompt-contaminated chip lines for arbitrary prompts', () => {
|
|
158
|
+
const before = ['older'];
|
|
159
|
+
const current = [
|
|
160
|
+
'older',
|
|
161
|
+
'你好快速视频生成深入研究图像生成帮我写作音乐生成更多',
|
|
162
|
+
];
|
|
163
|
+
expect(collectDoubaoTranscriptAdditions(before, current, '你好')).toBe('');
|
|
164
|
+
});
|
|
165
|
+
it('filters whitespace-normalized multiline prompt echoes and prompt-plus-chip artifacts', () => {
|
|
166
|
+
const before = ['older'];
|
|
167
|
+
const prompt = '第一行\n第二行';
|
|
168
|
+
expect(collectDoubaoTranscriptAdditions(before, ['older', '第一行 第二行'], prompt)).toBe('');
|
|
169
|
+
expect(collectDoubaoTranscriptAdditions(before, ['older', '第一行 第二行快速视频生成深入研究图像生成帮我写作音乐生成更多'], prompt)).toBe('');
|
|
170
|
+
});
|
|
171
|
+
it('keeps legitimate replies that discuss Doubao features', () => {
|
|
172
|
+
const before = ['older'];
|
|
173
|
+
const current = [
|
|
174
|
+
'older',
|
|
175
|
+
'图像生成和音乐生成目前都支持,但适用场景不同。',
|
|
176
|
+
];
|
|
177
|
+
expect(collectDoubaoTranscriptAdditions(before, current, 'irrelevant prompt')).toBe('图像生成和音乐生成目前都支持,但适用场景不同。');
|
|
178
|
+
});
|
|
179
|
+
it('keeps an exact chip string when it is the assistant reply rather than prompt contamination', () => {
|
|
180
|
+
const before = ['older'];
|
|
181
|
+
const current = [
|
|
182
|
+
'older',
|
|
183
|
+
'快速视频生成深入研究图像生成帮我写作音乐生成更多',
|
|
184
|
+
];
|
|
185
|
+
expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK')).toBe('快速视频生成深入研究图像生成帮我写作音乐生成更多');
|
|
186
|
+
});
|
|
187
|
+
it('filters combined sidebar chrome that appears as a new transcript line', () => {
|
|
188
|
+
const before = ['older'];
|
|
189
|
+
const current = [
|
|
190
|
+
'older',
|
|
191
|
+
'AI 创作云盘更多历史对话',
|
|
192
|
+
];
|
|
193
|
+
expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK')).toBe('');
|
|
194
|
+
});
|
|
195
|
+
it('filters transcript lines that only differ because the prompt was appended to existing page chrome', () => {
|
|
196
|
+
const before = [
|
|
197
|
+
'有什么我能帮你的吗?资讯:韩国三大运营商允许超流量用基本数据服务快速视频生成深入研究图像生成帮我写作音乐生成更多',
|
|
198
|
+
];
|
|
199
|
+
const current = [
|
|
200
|
+
'有什么我能帮你的吗?资讯:韩国三大运营商允许超流量用基本数据服务快速视频生成深入研究图像生成帮我写作音乐生成更多测试一下,只回复OK',
|
|
201
|
+
];
|
|
202
|
+
expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK', (value) => value.replace('测试一下,只回复OK', '').trim())).toBe('');
|
|
203
|
+
});
|
|
204
|
+
it('treats only the exact landing-page chip string as UI noise', () => {
|
|
205
|
+
expect(__test__.clickSendButtonScript()).not.toContain('document,');
|
|
206
|
+
expect(__test__.clickSendButtonScript()).toContain('bestScore >= 200');
|
|
207
|
+
expect(__test__.clickSendButtonScript()).not.toContain("|| !!button.closest('.chat-input-button')");
|
|
208
|
+
expect(__test__.clickSendButtonScript()).toContain("button.getAttribute('type') === 'submit') score += 1200");
|
|
209
|
+
expect(__test__.composerStateScript()).toContain("(composer.innerText || '').trim() || (composer.textContent || '').trim()");
|
|
210
|
+
expect(__test__.detectDoubaoVerificationScript()).not.toContain('document.body?.innerText');
|
|
211
|
+
expect(__test__.detectDoubaoVerificationScript()).not.toContain('[class*=\"verify\"]');
|
|
212
|
+
expect(__test__.detectDoubaoVerificationScript()).not.toContain('[class*=\"captcha\"]');
|
|
213
|
+
expect(__test__.detectDoubaoVerificationScript()).not.toContain('document.body?.children');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
describe('waitForDoubaoResponse', () => {
|
|
217
|
+
it('allows transcript fallback on local chat urls when new transcript lines appear', async () => {
|
|
218
|
+
const page = createPageMock();
|
|
219
|
+
const evaluate = vi.mocked(page.evaluate);
|
|
220
|
+
const wait = vi.mocked(page.wait);
|
|
221
|
+
evaluate
|
|
222
|
+
.mockResolvedValueOnce({ detected: false })
|
|
223
|
+
.mockResolvedValueOnce('https://www.doubao.com/chat/local_123')
|
|
224
|
+
.mockResolvedValueOnce([])
|
|
225
|
+
.mockResolvedValueOnce('https://www.doubao.com/chat/local_123')
|
|
226
|
+
.mockResolvedValueOnce(['older', '真正的回答']);
|
|
227
|
+
const result = await waitForDoubaoResponse(page, ['older'], [], '测试一下,只回复OK', 2);
|
|
228
|
+
expect(wait).toHaveBeenCalled();
|
|
229
|
+
expect(result).toBe('真正的回答');
|
|
230
|
+
});
|
|
231
|
+
it('does not suppress assistant turns that happen to match landing-page chip text', async () => {
|
|
232
|
+
const page = createPageMock();
|
|
233
|
+
const evaluate = vi.mocked(page.evaluate);
|
|
234
|
+
evaluate
|
|
235
|
+
.mockResolvedValueOnce({ detected: false })
|
|
236
|
+
.mockResolvedValueOnce('https://www.doubao.com/chat')
|
|
237
|
+
.mockResolvedValueOnce([
|
|
238
|
+
{ Role: 'Assistant', Text: '快速视频生成深入研究图像生成帮我写作音乐生成更多' },
|
|
239
|
+
]);
|
|
240
|
+
const result = await waitForDoubaoResponse(page, [], [], '测试一下,只回复OK', 2);
|
|
241
|
+
expect(result).toBe('快速视频生成深入研究图像生成帮我写作音乐生成更多');
|
|
242
|
+
});
|
|
243
|
+
it('raises a command error when a verification challenge appears during polling', async () => {
|
|
244
|
+
const page = createPageMock();
|
|
245
|
+
const evaluate = vi.mocked(page.evaluate);
|
|
246
|
+
evaluate.mockResolvedValueOnce({ detected: true, reason: '请完成安全验证' });
|
|
247
|
+
await expect(waitForDoubaoResponse(page, [], [], '你好', 2)).rejects.toBeInstanceOf(CommandExecutionError);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
11
250
|
describe('mergeTranscriptSnapshots', () => {
|
|
12
251
|
it('extends the transcript when the next snapshot overlaps with the tail', () => {
|
|
13
252
|
const merged = mergeTranscriptSnapshots('Alice 00:00\nHello team\nBob 00:05\nHi', 'Bob 00:05\nHi\nAlice 00:10\nNext topic');
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'eastmoney',
|
|
5
|
+
name: 'hot-rank',
|
|
6
|
+
description: '东方财富热股榜',
|
|
7
|
+
domain: 'guba.eastmoney.com',
|
|
8
|
+
strategy: Strategy.COOKIE,
|
|
9
|
+
navigateBefore: true,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'limit', type: 'int', default: 20, help: '返回数量' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['rank', 'symbol', 'name', 'price', 'changePercent', 'heat', 'url'],
|
|
14
|
+
func: async (page, kwargs) => {
|
|
15
|
+
await page.goto('https://guba.eastmoney.com/rank/');
|
|
16
|
+
await page.wait({ selector: '#rankCont', timeout: 15000 });
|
|
17
|
+
const data = await page.evaluate(`
|
|
18
|
+
(() => {
|
|
19
|
+
const cleanText = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
20
|
+
const rows = document.querySelectorAll('table.rank_table tbody tr');
|
|
21
|
+
const results = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
let rank = 0;
|
|
24
|
+
rows.forEach((row) => {
|
|
25
|
+
const codeEl = row.querySelector('a.stock_code');
|
|
26
|
+
const href = codeEl?.getAttribute('href') || '';
|
|
27
|
+
const symbolMatch = href.match(/(\\d{6})/);
|
|
28
|
+
if (!symbolMatch) return;
|
|
29
|
+
const symbol = symbolMatch[1];
|
|
30
|
+
if (seen.has(symbol)) return;
|
|
31
|
+
seen.add(symbol);
|
|
32
|
+
rank++;
|
|
33
|
+
const tds = row.querySelectorAll('td');
|
|
34
|
+
results.push({
|
|
35
|
+
rank,
|
|
36
|
+
symbol,
|
|
37
|
+
name: row.querySelector('td.nametd a[title]')?.getAttribute('title') || cleanText(row.querySelector('td.nametd')),
|
|
38
|
+
price: tds[6] ? cleanText(tds[6]) : '',
|
|
39
|
+
changePercent: tds[8] ? cleanText(tds[8]) : '',
|
|
40
|
+
heat: cleanText(row.querySelector('td.fans')),
|
|
41
|
+
url: 'https://guba.eastmoney.com/list,' + symbol + '.html',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
return results;
|
|
45
|
+
})()
|
|
46
|
+
`);
|
|
47
|
+
if (!Array.isArray(data)) return [];
|
|
48
|
+
return data.slice(0, kwargs.limit);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './hot-rank.js';
|
|
4
|
+
|
|
5
|
+
describe('eastmoney hot-rank command', () => {
|
|
6
|
+
it('registers the command with correct metadata', () => {
|
|
7
|
+
const command = getRegistry().get('eastmoney/hot-rank');
|
|
8
|
+
expect(command).toBeDefined();
|
|
9
|
+
expect(command).toMatchObject({
|
|
10
|
+
site: 'eastmoney',
|
|
11
|
+
name: 'hot-rank',
|
|
12
|
+
description: expect.stringContaining('东方财富'),
|
|
13
|
+
domain: 'guba.eastmoney.com',
|
|
14
|
+
navigateBefore: true,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns hot stock data from the page', async () => {
|
|
19
|
+
const command = getRegistry().get('eastmoney/hot-rank');
|
|
20
|
+
const mockData = [
|
|
21
|
+
{ rank: 1, symbol: '600519', name: '贵州茅台', price: '1680.00', changePercent: '+2.35%', heat: '28.5万', url: 'https://guba.eastmoney.com/list,600519.html' },
|
|
22
|
+
{ rank: 2, symbol: '000001', name: '平安银行', price: '12.50', changePercent: '-0.80%', heat: '15.2万', url: 'https://guba.eastmoney.com/list,000001.html' },
|
|
23
|
+
];
|
|
24
|
+
const page = {
|
|
25
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
evaluate: vi.fn().mockResolvedValue(mockData),
|
|
28
|
+
};
|
|
29
|
+
const result = await command.func(page, { limit: 20 });
|
|
30
|
+
expect(result).toHaveLength(2);
|
|
31
|
+
expect(result[0]).toEqual(mockData[0]);
|
|
32
|
+
expect(page.goto).toHaveBeenCalledWith('https://guba.eastmoney.com/rank/');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('respects the limit parameter', async () => {
|
|
36
|
+
const command = getRegistry().get('eastmoney/hot-rank');
|
|
37
|
+
const mockData = Array.from({ length: 30 }, (_, i) => ({
|
|
38
|
+
rank: i + 1, symbol: `${i}`, name: `stock${i}`, price: '0', changePercent: '0%', heat: '0', url: '',
|
|
39
|
+
}));
|
|
40
|
+
const page = {
|
|
41
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
42
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
evaluate: vi.fn().mockResolvedValue(mockData),
|
|
44
|
+
};
|
|
45
|
+
const result = await command.func(page, { limit: 10 });
|
|
46
|
+
expect(result).toHaveLength(10);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns empty array when evaluate returns non-array', async () => {
|
|
50
|
+
const command = getRegistry().get('eastmoney/hot-rank');
|
|
51
|
+
const page = {
|
|
52
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
evaluate: vi.fn().mockResolvedValue(null),
|
|
55
|
+
};
|
|
56
|
+
const result = await command.func(page, { limit: 20 });
|
|
57
|
+
expect(result).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { IPage } from '@jackwener/opencli/types';
|
|
3
|
+
import { __test__ } from './image.js';
|
|
4
|
+
|
|
5
|
+
describe('grok image helpers', () => {
|
|
6
|
+
describe('isOnGrok', () => {
|
|
7
|
+
const fakePage = (url: string | Error): IPage =>
|
|
8
|
+
({ evaluate: () => url instanceof Error ? Promise.reject(url) : Promise.resolve(url) }) as unknown as IPage;
|
|
9
|
+
|
|
10
|
+
it('returns true for grok.com URLs', async () => {
|
|
11
|
+
expect(await __test__.isOnGrok(fakePage('https://grok.com/'))).toBe(true);
|
|
12
|
+
expect(await __test__.isOnGrok(fakePage('https://grok.com/chat/abc123'))).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns true for grok.com subdomains', async () => {
|
|
16
|
+
expect(await __test__.isOnGrok(fakePage('https://assets.grok.com/foo'))).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns false for non-grok domains', async () => {
|
|
20
|
+
expect(await __test__.isOnGrok(fakePage('https://fakegrok.com/'))).toBe(false);
|
|
21
|
+
expect(await __test__.isOnGrok(fakePage('about:blank'))).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns false when evaluate throws (detached tab)', async () => {
|
|
25
|
+
expect(await __test__.isOnGrok(fakePage(new Error('detached')))).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('normalizes boolean flags', () => {
|
|
30
|
+
expect(__test__.normalizeBooleanFlag(true)).toBe(true);
|
|
31
|
+
expect(__test__.normalizeBooleanFlag('true')).toBe(true);
|
|
32
|
+
expect(__test__.normalizeBooleanFlag('1')).toBe(true);
|
|
33
|
+
expect(__test__.normalizeBooleanFlag('yes')).toBe(true);
|
|
34
|
+
expect(__test__.normalizeBooleanFlag('on')).toBe(true);
|
|
35
|
+
|
|
36
|
+
expect(__test__.normalizeBooleanFlag(false)).toBe(false);
|
|
37
|
+
expect(__test__.normalizeBooleanFlag('false')).toBe(false);
|
|
38
|
+
expect(__test__.normalizeBooleanFlag(undefined)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('dedupes images by src', () => {
|
|
42
|
+
const deduped = __test__.dedupeBySrc([
|
|
43
|
+
{ src: 'https://a.example/1.jpg', w: 500, h: 500 },
|
|
44
|
+
{ src: 'https://a.example/1.jpg', w: 500, h: 500 },
|
|
45
|
+
{ src: 'https://a.example/2.jpg', w: 500, h: 500 },
|
|
46
|
+
{ src: '', w: 500, h: 500 },
|
|
47
|
+
]);
|
|
48
|
+
expect(deduped.map(i => i.src)).toEqual([
|
|
49
|
+
'https://a.example/1.jpg',
|
|
50
|
+
'https://a.example/2.jpg',
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('builds a deterministic-ish signature order-independent by src', () => {
|
|
55
|
+
const sigA = __test__.imagesSignature([
|
|
56
|
+
{ src: 'https://a.example/1.jpg', w: 1, h: 1 },
|
|
57
|
+
{ src: 'https://a.example/2.jpg', w: 1, h: 1 },
|
|
58
|
+
]);
|
|
59
|
+
const sigB = __test__.imagesSignature([
|
|
60
|
+
{ src: 'https://a.example/2.jpg', w: 1, h: 1 },
|
|
61
|
+
{ src: 'https://a.example/1.jpg', w: 1, h: 1 },
|
|
62
|
+
]);
|
|
63
|
+
expect(sigA).toBe(sigB);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('maps content-type to sensible image extensions', () => {
|
|
67
|
+
expect(__test__.extFromContentType('image/png')).toBe('png');
|
|
68
|
+
expect(__test__.extFromContentType('image/webp')).toBe('webp');
|
|
69
|
+
expect(__test__.extFromContentType('image/gif')).toBe('gif');
|
|
70
|
+
expect(__test__.extFromContentType('image/jpeg')).toBe('jpg');
|
|
71
|
+
expect(__test__.extFromContentType(undefined)).toBe('jpg');
|
|
72
|
+
expect(__test__.extFromContentType('')).toBe('jpg');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('builds filenames with a stable sha1 slice tied to the src', () => {
|
|
76
|
+
const a1 = __test__.buildFilename('https://a.example/1.jpg', 'image/jpeg');
|
|
77
|
+
const a2 = __test__.buildFilename('https://a.example/1.jpg', 'image/jpeg');
|
|
78
|
+
const b1 = __test__.buildFilename('https://a.example/2.jpg', 'image/png');
|
|
79
|
+
// Same URL → same 12-char hash slice (timestamps may differ).
|
|
80
|
+
expect(a1.split('-')[2].split('.')[0]).toBe(a2.split('-')[2].split('.')[0]);
|
|
81
|
+
expect(a1.split('-')[2].split('.')[0]).not.toBe(b1.split('-')[2].split('.')[0]);
|
|
82
|
+
expect(a1.endsWith('.jpg')).toBe(true);
|
|
83
|
+
expect(b1.endsWith('.png')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('only accepts image bubbles that appeared after the baseline', () => {
|
|
87
|
+
const candidate = __test__.pickLatestImageCandidate([
|
|
88
|
+
[{ src: 'https://a.example/stale.jpg', w: 512, h: 512 }],
|
|
89
|
+
[],
|
|
90
|
+
[{ src: 'https://a.example/fresh.jpg', w: 1024, h: 1024 }],
|
|
91
|
+
], 1);
|
|
92
|
+
|
|
93
|
+
expect(candidate).toEqual([
|
|
94
|
+
{ src: 'https://a.example/fresh.jpg', w: 1024, h: 1024 },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('does not reuse stale images when no new image bubble appears after baseline', () => {
|
|
99
|
+
const candidate = __test__.pickLatestImageCandidate([
|
|
100
|
+
[{ src: 'https://a.example/stale.jpg', w: 512, h: 512 }],
|
|
101
|
+
[],
|
|
102
|
+
[],
|
|
103
|
+
], 1);
|
|
104
|
+
|
|
105
|
+
expect(candidate).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
});
|