@jackwener/opencli 1.7.3 → 1.7.5

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 (197) hide show
  1. package/README.md +81 -59
  2. package/README.zh-CN.md +93 -67
  3. package/cli-manifest.json +5015 -2975
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/favorite.js +18 -13
  8. package/clis/binance/depth.js +3 -4
  9. package/clis/boss/utils.js +2 -3
  10. package/clis/chatgpt-app/ax.js +6 -3
  11. package/clis/deepseek/ask.js +74 -0
  12. package/clis/deepseek/history.js +25 -0
  13. package/clis/deepseek/new.js +20 -0
  14. package/clis/deepseek/read.js +22 -0
  15. package/clis/deepseek/status.js +24 -0
  16. package/clis/deepseek/utils.js +208 -0
  17. package/clis/douban/search.js +1 -0
  18. package/clis/douban/search.test.js +11 -0
  19. package/clis/douban/subject.js +20 -93
  20. package/clis/douban/subject.test.js +11 -0
  21. package/clis/douban/utils.js +250 -8
  22. package/clis/douban/utils.test.js +179 -4
  23. package/clis/doubao/utils.js +319 -130
  24. package/clis/doubao/utils.test.js +241 -2
  25. package/clis/eastmoney/_secid.js +78 -0
  26. package/clis/eastmoney/announcement.js +52 -0
  27. package/clis/eastmoney/convertible.js +73 -0
  28. package/clis/eastmoney/etf.js +65 -0
  29. package/clis/eastmoney/holders.js +78 -0
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/eastmoney/index-board.js +96 -0
  33. package/clis/eastmoney/kline.js +87 -0
  34. package/clis/eastmoney/kuaixun.js +54 -0
  35. package/clis/eastmoney/longhu.js +67 -0
  36. package/clis/eastmoney/money-flow.js +78 -0
  37. package/clis/eastmoney/northbound.js +57 -0
  38. package/clis/eastmoney/quote.js +107 -0
  39. package/clis/eastmoney/rank.js +94 -0
  40. package/clis/eastmoney/sectors.js +76 -0
  41. package/clis/google-scholar/search.js +58 -0
  42. package/clis/google-scholar/search.test.js +23 -0
  43. package/clis/gov-law/commands.test.js +39 -0
  44. package/clis/gov-law/recent.js +22 -0
  45. package/clis/gov-law/search.js +41 -0
  46. package/clis/gov-law/shared.js +51 -0
  47. package/clis/gov-policy/commands.test.js +27 -0
  48. package/clis/gov-policy/recent.js +47 -0
  49. package/clis/gov-policy/search.js +48 -0
  50. package/clis/grok/image.test.ts +107 -0
  51. package/clis/grok/image.ts +356 -0
  52. package/clis/nowcoder/companies.js +23 -0
  53. package/clis/nowcoder/creators.js +27 -0
  54. package/clis/nowcoder/detail.js +61 -0
  55. package/clis/nowcoder/experience.js +36 -0
  56. package/clis/nowcoder/hot.js +24 -0
  57. package/clis/nowcoder/jobs.js +21 -0
  58. package/clis/nowcoder/notifications.js +29 -0
  59. package/clis/nowcoder/papers.js +40 -0
  60. package/clis/nowcoder/practice.js +37 -0
  61. package/clis/nowcoder/recommend.js +30 -0
  62. package/clis/nowcoder/referral.js +39 -0
  63. package/clis/nowcoder/salary.js +40 -0
  64. package/clis/nowcoder/search.js +49 -0
  65. package/clis/nowcoder/suggest.js +33 -0
  66. package/clis/nowcoder/topics.js +27 -0
  67. package/clis/nowcoder/trending.js +25 -0
  68. package/clis/tdx/hot-rank.js +47 -0
  69. package/clis/tdx/hot-rank.test.js +59 -0
  70. package/clis/ths/hot-rank.js +49 -0
  71. package/clis/ths/hot-rank.test.js +64 -0
  72. package/clis/twitter/bookmarks.js +2 -1
  73. package/clis/twitter/list-add.js +337 -0
  74. package/clis/twitter/list-add.test.js +15 -0
  75. package/clis/twitter/list-remove.js +297 -0
  76. package/clis/twitter/list-remove.test.js +14 -0
  77. package/clis/twitter/list-tweets.js +185 -0
  78. package/clis/twitter/list-tweets.test.js +108 -0
  79. package/clis/twitter/lists.js +134 -47
  80. package/clis/twitter/lists.test.js +105 -38
  81. package/clis/uiverse/_shared.js +368 -0
  82. package/clis/uiverse/_shared.test.js +55 -0
  83. package/clis/uiverse/code.js +47 -0
  84. package/clis/uiverse/preview.js +71 -0
  85. package/clis/wanfang/search.js +66 -0
  86. package/clis/wanfang/search.test.js +23 -0
  87. package/clis/web/read.js +1 -1
  88. package/clis/weixin/download.js +3 -2
  89. package/clis/xiaohongshu/comments.js +2 -2
  90. package/clis/xiaohongshu/comments.test.js +46 -25
  91. package/clis/xiaohongshu/download.js +6 -7
  92. package/clis/xiaohongshu/download.test.js +17 -5
  93. package/clis/xiaohongshu/note-helpers.js +46 -12
  94. package/clis/xiaohongshu/note.js +3 -5
  95. package/clis/xiaohongshu/note.test.js +52 -25
  96. package/clis/xiaohongshu/publish.js +149 -28
  97. package/clis/xiaohongshu/publish.test.js +319 -6
  98. package/clis/xiaoyuzhou/auth.js +303 -0
  99. package/clis/xiaoyuzhou/auth.test.js +124 -0
  100. package/clis/xiaoyuzhou/download.js +53 -0
  101. package/clis/xiaoyuzhou/download.test.js +135 -0
  102. package/clis/xiaoyuzhou/episode.js +9 -4
  103. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  104. package/clis/xiaoyuzhou/podcast.js +9 -4
  105. package/clis/xiaoyuzhou/transcript.js +76 -0
  106. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  107. package/clis/xiaoyuzhou/utils.js +0 -40
  108. package/clis/xiaoyuzhou/utils.test.js +15 -75
  109. package/clis/youtube/feed.js +120 -0
  110. package/clis/youtube/history.js +118 -0
  111. package/clis/youtube/like.js +62 -0
  112. package/clis/youtube/playlist.js +97 -0
  113. package/clis/youtube/subscribe.js +71 -0
  114. package/clis/youtube/subscriptions.js +57 -0
  115. package/clis/youtube/unlike.js +62 -0
  116. package/clis/youtube/unsubscribe.js +71 -0
  117. package/clis/youtube/utils.js +122 -0
  118. package/clis/youtube/utils.test.js +32 -1
  119. package/clis/youtube/watch-later.js +76 -0
  120. package/clis/zsxq/dynamics.js +1 -1
  121. package/clis/zsxq/utils.js +6 -3
  122. package/clis/zsxq/utils.test.js +31 -0
  123. package/dist/src/browser/base-page.d.ts +1 -1
  124. package/dist/src/browser/base-page.js +25 -5
  125. package/dist/src/browser/bridge.d.ts +3 -0
  126. package/dist/src/browser/bridge.js +52 -15
  127. package/dist/src/browser/cdp.js +2 -1
  128. package/dist/src/browser/daemon-client.d.ts +7 -4
  129. package/dist/src/browser/daemon-client.js +6 -1
  130. package/dist/src/browser/daemon-client.test.js +40 -1
  131. package/dist/src/browser/dom-snapshot.js +20 -3
  132. package/dist/src/browser/page.d.ts +18 -5
  133. package/dist/src/browser/page.js +96 -15
  134. package/dist/src/browser/page.test.js +158 -1
  135. package/dist/src/browser/target-errors.d.ts +23 -0
  136. package/dist/src/browser/target-errors.js +29 -0
  137. package/dist/src/browser/target-errors.test.js +61 -0
  138. package/dist/src/browser/target-resolver.d.ts +57 -0
  139. package/dist/src/browser/target-resolver.js +298 -0
  140. package/dist/src/browser/target-resolver.test.js +43 -0
  141. package/dist/src/browser.test.js +38 -1
  142. package/dist/src/cli.js +272 -187
  143. package/dist/src/cli.test.js +167 -90
  144. package/dist/src/commanderAdapter.d.ts +0 -1
  145. package/dist/src/commanderAdapter.js +2 -16
  146. package/dist/src/commanderAdapter.test.js +1 -1
  147. package/dist/src/commands/daemon.d.ts +4 -2
  148. package/dist/src/commands/daemon.js +22 -2
  149. package/dist/src/commands/daemon.test.js +65 -2
  150. package/dist/src/completion-shared.js +2 -5
  151. package/dist/src/daemon.js +10 -0
  152. package/dist/src/doctor.d.ts +1 -0
  153. package/dist/src/doctor.js +32 -9
  154. package/dist/src/doctor.test.js +28 -12
  155. package/dist/src/download/article-download.d.ts +1 -0
  156. package/dist/src/download/article-download.js +3 -0
  157. package/dist/src/download/article-download.test.js +39 -0
  158. package/dist/src/external-clis.yaml +2 -2
  159. package/dist/src/logger.d.ts +2 -2
  160. package/dist/src/logger.js +3 -3
  161. package/dist/src/output.js +1 -5
  162. package/dist/src/output.test.js +0 -21
  163. package/dist/src/pipeline/steps/transform.js +1 -1
  164. package/dist/src/pipeline/template.d.ts +1 -0
  165. package/dist/src/pipeline/template.js +11 -3
  166. package/dist/src/pipeline/template.test.js +3 -0
  167. package/dist/src/pipeline/transform.test.js +14 -0
  168. package/dist/src/plugin.d.ts +8 -9
  169. package/dist/src/plugin.js +24 -28
  170. package/dist/src/plugin.test.js +16 -60
  171. package/dist/src/registry.d.ts +1 -0
  172. package/dist/src/registry.js +3 -2
  173. package/dist/src/registry.test.js +22 -0
  174. package/dist/src/types.d.ts +15 -6
  175. package/package.json +1 -1
  176. package/clis/twitter/lists-parser.js +0 -77
  177. package/clis/twitter/lists.d.ts +0 -5
  178. package/dist/src/cascade.d.ts +0 -46
  179. package/dist/src/cascade.js +0 -135
  180. package/dist/src/explore.d.ts +0 -99
  181. package/dist/src/explore.js +0 -402
  182. package/dist/src/generate-verified.d.ts +0 -105
  183. package/dist/src/generate-verified.js +0 -696
  184. package/dist/src/generate-verified.test.js +0 -925
  185. package/dist/src/generate.d.ts +0 -46
  186. package/dist/src/generate.js +0 -117
  187. package/dist/src/record.d.ts +0 -96
  188. package/dist/src/record.js +0 -657
  189. package/dist/src/record.test.js +0 -293
  190. package/dist/src/skill-generate.d.ts +0 -30
  191. package/dist/src/skill-generate.js +0 -75
  192. package/dist/src/skill-generate.test.js +0 -173
  193. package/dist/src/synthesize.d.ts +0 -97
  194. package/dist/src/synthesize.js +0 -208
  195. /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
  196. /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
  197. /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
@@ -1,5 +1,41 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { mergeTranscriptSnapshots, parseDoubaoConversationId } from './utils.js';
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,78 @@
1
+ // Shared helpers for resolving eastmoney "secid" (市场.代码).
2
+ //
3
+ // Markets:
4
+ // 1.XXXXXX → Shanghai A (SSE)
5
+ // 0.XXXXXX → Shenzhen A (SZSE) or Beijing (BSE) — eastmoney groups both under 0
6
+ // 116.XXXXX → Hong Kong
7
+ // 105.SYMBOL → NASDAQ
8
+ // 106.SYMBOL → NYSE
9
+ // 107.SYMBOL → AMEX (US)
10
+
11
+ const A_PREFIX_TO_MARKET = /** @param {string} c */ (c) => {
12
+ if (/^(60|68|90|113|900)/.test(c)) return '1'; // SH (A + STAR + old B)
13
+ if (/^(00|30|20)/.test(c)) return '0'; // SZ (A + ChiNext + B)
14
+ if (/^(4|8|920|83|87)/.test(c)) return '0'; // BJ (eastmoney uses 0.)
15
+ return '0';
16
+ };
17
+
18
+ /**
19
+ * Resolve various user inputs to an eastmoney `secid`.
20
+ * - "600000" → "1.600000"
21
+ * - "sh600000" → "1.600000"
22
+ * - "sz000001" → "0.000001"
23
+ * - "bj430047" → "0.430047"
24
+ * - "hk00700" / "00700.HK" → "116.00700"
25
+ * - "us.AAPL" / "AAPL" → "105.AAPL"
26
+ * - "1.600000" → passed through
27
+ * @param {string} input
28
+ * @returns {string}
29
+ */
30
+ // Known eastmoney market numeric prefixes. Narrow whitelist so that inputs like
31
+ // "00700.HK" are NOT mistakenly treated as secids just because they look like
32
+ // "<digits>.<alphanumeric>".
33
+ const KNOWN_MARKET_PREFIXES = new Set(['0', '1', '100', '105', '106', '107', '116', '140', '150', '151', '152', '155', '156']);
34
+
35
+ export function resolveSecid(input) {
36
+ const raw = String(input || '').trim();
37
+ if (!raw) throw new Error('empty symbol');
38
+ const secidMatch = raw.match(/^(\d{1,3})\.([A-Za-z0-9]+)$/);
39
+ if (secidMatch && KNOWN_MARKET_PREFIXES.has(secidMatch[1])) return raw; // already a secid
40
+ const lower = raw.toLowerCase();
41
+
42
+ // market-prefixed Chinese code
43
+ const pref = lower.match(/^(sh|sz|bj)(\d{6})$/);
44
+ if (pref) {
45
+ const [, mk, code] = pref;
46
+ return (mk === 'sh' ? '1' : '0') + '.' + code;
47
+ }
48
+
49
+ // hk prefix
50
+ const hk = lower.match(/^hk(\d{4,5})$/) || lower.match(/^(\d{4,5})\.hk$/);
51
+ if (hk) return '116.' + hk[1].padStart(5, '0');
52
+
53
+ // us.SYMBOL or SYMBOL.N/.O (treat all as NASDAQ by default; .N as NYSE)
54
+ const usDot = lower.match(/^([a-z.\-]+)\.([no])$/);
55
+ if (usDot) return (usDot[2] === 'n' ? '106' : '105') + '.' + usDot[1].toUpperCase();
56
+ const usPref = lower.match(/^us\.([a-z.\-]+)$/);
57
+ if (usPref) return '105.' + usPref[1].toUpperCase();
58
+
59
+ // bare 6-digit Chinese code
60
+ if (/^\d{6}$/.test(raw)) return A_PREFIX_TO_MARKET(raw) + '.' + raw;
61
+
62
+ // bare US ticker — uppercase letters only
63
+ if (/^[A-Z.\-]{1,8}$/.test(raw)) return '105.' + raw;
64
+
65
+ throw new Error(`Unrecognized symbol: ${input}`);
66
+ }
67
+
68
+ /**
69
+ * Normalize a list of user inputs separated by comma / space / Chinese comma.
70
+ * @param {string} s
71
+ * @returns {string[]}
72
+ */
73
+ export function splitSymbols(s) {
74
+ return String(s || '')
75
+ .split(/[,,\s]+/)
76
+ .map((x) => x.trim())
77
+ .filter(Boolean);
78
+ }
@@ -0,0 +1,52 @@
1
+ // eastmoney announcement — listed company filings/announcements feed.
2
+ //
3
+ // opencli eastmoney announcement
4
+ // opencli eastmoney announcement --market SHA --limit 30
5
+
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { CliError } from '@jackwener/opencli/errors';
8
+
9
+ cli({
10
+ site: 'eastmoney',
11
+ name: 'announcement',
12
+ description: '上市公司公告(按交易所筛选)',
13
+ domain: 'np-anotice-stock.eastmoney.com',
14
+ strategy: Strategy.PUBLIC,
15
+ browser: false,
16
+ args: [
17
+ { name: 'market', type: 'string', default: 'SHA,SZA,BJA', help: '交易所:SHA (沪) / SZA (深) / BJA (北) 可逗号分隔' },
18
+ { name: 'limit', type: 'int', default: 20, help: '返回数量 (max 100)' },
19
+ ],
20
+ columns: ['time', 'code', 'name', 'title', 'category', 'url'],
21
+ func: async (_page, args) => {
22
+ const market = String(args.market ?? 'SHA,SZA,BJA').trim() || 'SHA,SZA,BJA';
23
+ const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
24
+
25
+ const url = new URL('https://np-anotice-stock.eastmoney.com/api/security/ann');
26
+ url.searchParams.set('page_size', String(limit));
27
+ url.searchParams.set('page_index', '1');
28
+ url.searchParams.set('ann_type', market);
29
+ url.searchParams.set('client_source', 'web');
30
+ url.searchParams.set('f_node', '0');
31
+ url.searchParams.set('s_node', '0');
32
+
33
+ const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
34
+ if (!resp.ok) throw new CliError('HTTP_ERROR', `announcement failed: HTTP ${resp.status}`);
35
+ const data = await resp.json();
36
+ const list = Array.isArray(data?.data?.list) ? data.data.list : [];
37
+ if (list.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no announcement data');
38
+
39
+ return list.slice(0, limit).map((it) => {
40
+ const primary = Array.isArray(it.codes) && it.codes.length > 0 ? it.codes[0] : {};
41
+ const cat = Array.isArray(it.columns) && it.columns.length > 0 ? it.columns[0]?.column_name : '';
42
+ return {
43
+ time: String(it.notice_date || it.display_time || '').slice(0, 19),
44
+ code: primary.stock_code || '',
45
+ name: primary.short_name || '',
46
+ title: it.title || it.title_ch || '',
47
+ category: cat || '',
48
+ url: `https://data.eastmoney.com/notices/detail/${primary.stock_code || ''}/${it.art_code || ''}.html`,
49
+ };
50
+ });
51
+ },
52
+ });
@@ -0,0 +1,73 @@
1
+ // eastmoney convertible — on-market convertible bond listing.
2
+ //
3
+ // opencli eastmoney convertible
4
+ // opencli eastmoney convertible --sort premium --limit 30
5
+
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { CliError } from '@jackwener/opencli/errors';
8
+
9
+ const SORTS = {
10
+ change: { fid: 'f3', order: 'desc' },
11
+ drop: { fid: 'f3', order: 'asc' },
12
+ turnover: { fid: 'f6', order: 'desc' },
13
+ price: { fid: 'f2', order: 'desc' },
14
+ premium: { fid: 'f237', order: 'desc' }, // 转股溢价率
15
+ value: { fid: 'f236', order: 'desc' }, // 转股价值
16
+ ytm: { fid: 'f239', order: 'desc' }, // 到期收益率
17
+ };
18
+
19
+ cli({
20
+ site: 'eastmoney',
21
+ name: 'convertible',
22
+ description: '可转债行情列表(默认按成交额排序)',
23
+ domain: 'push2.eastmoney.com',
24
+ strategy: Strategy.PUBLIC,
25
+ browser: false,
26
+ args: [
27
+ { name: 'sort', type: 'string', default: 'turnover', help: '排序:turnover / change / drop / price / premium' },
28
+ { name: 'limit', type: 'int', default: 20, help: '返回数量 (max 100)' },
29
+ ],
30
+ columns: ['rank', 'bondCode', 'bondName', 'bondPrice', 'bondChangePct', 'stockCode', 'stockName', 'stockPrice', 'stockChangePct', 'convPrice', 'convValue', 'convPremiumPct', 'remainingYears', 'ytm', 'listDate'],
31
+ func: async (_page, args) => {
32
+ const sortKey = String(args.sort ?? 'turnover').toLowerCase();
33
+ const sort = SORTS[sortKey];
34
+ if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort "${sortKey}". Valid: ${Object.keys(SORTS).join(', ')}`);
35
+ const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
36
+
37
+ const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');
38
+ url.searchParams.set('pn', '1');
39
+ url.searchParams.set('pz', String(limit));
40
+ url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');
41
+ url.searchParams.set('np', '1');
42
+ url.searchParams.set('fltt', '2');
43
+ url.searchParams.set('invt', '2');
44
+ url.searchParams.set('fid', sort.fid);
45
+ url.searchParams.set('fs', 'b:MK0354');
46
+ url.searchParams.set('fields', 'f12,f14,f2,f3,f6,f229,f230,f232,f234,f235,f236,f237,f238,f239,f243');
47
+ url.searchParams.set('ut', 'bd1d9ddb04089700cf9c27f6f7426281');
48
+
49
+ const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
50
+ if (!resp.ok) throw new CliError('HTTP_ERROR', `convertible failed: HTTP ${resp.status}`);
51
+ const data = await resp.json();
52
+ const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
53
+ if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no convertible data');
54
+
55
+ return diff.slice(0, limit).map((it, i) => ({
56
+ rank: i + 1,
57
+ bondCode: it.f12,
58
+ bondName: it.f14,
59
+ bondPrice: it.f2,
60
+ bondChangePct: it.f3,
61
+ stockCode: it.f232,
62
+ stockName: it.f234,
63
+ stockPrice: it.f229,
64
+ stockChangePct: it.f230,
65
+ convPrice: it.f235,
66
+ convValue: it.f236,
67
+ convPremiumPct: it.f237,
68
+ remainingYears: it.f238,
69
+ ytm: it.f239,
70
+ listDate: String(it.f243 ?? ''),
71
+ }));
72
+ },
73
+ });
@@ -0,0 +1,65 @@
1
+ // eastmoney etf — ETF ranking by change / turnover.
2
+ //
3
+ // opencli eastmoney etf
4
+ // opencli eastmoney etf --sort change --limit 30
5
+
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { CliError } from '@jackwener/opencli/errors';
8
+
9
+ const SORTS = {
10
+ turnover: { fid: 'f6', order: 'desc' },
11
+ change: { fid: 'f3', order: 'desc' },
12
+ drop: { fid: 'f3', order: 'asc' },
13
+ volume: { fid: 'f5', order: 'desc' },
14
+ rate: { fid: 'f8', order: 'desc' },
15
+ };
16
+
17
+ cli({
18
+ site: 'eastmoney',
19
+ name: 'etf',
20
+ description: 'ETF 列表按成交额/涨跌幅排行',
21
+ domain: 'push2.eastmoney.com',
22
+ strategy: Strategy.PUBLIC,
23
+ browser: false,
24
+ args: [
25
+ { name: 'sort', type: 'string', default: 'turnover', help: '排序:turnover / change / drop / volume / rate' },
26
+ { name: 'limit', type: 'int', default: 20, help: '返回数量 (max 100)' },
27
+ ],
28
+ columns: ['rank', 'code', 'name', 'price', 'changePercent', 'change', 'turnover', 'volume', 'turnoverRate'],
29
+ func: async (_page, args) => {
30
+ const sortKey = String(args.sort ?? 'turnover').toLowerCase();
31
+ const sort = SORTS[sortKey];
32
+ if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort "${sortKey}". Valid: ${Object.keys(SORTS).join(', ')}`);
33
+ const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
34
+
35
+ const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');
36
+ url.searchParams.set('pn', '1');
37
+ url.searchParams.set('pz', String(limit));
38
+ url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');
39
+ url.searchParams.set('np', '1');
40
+ url.searchParams.set('fltt', '2');
41
+ url.searchParams.set('invt', '2');
42
+ url.searchParams.set('fid', sort.fid);
43
+ url.searchParams.set('fs', 'b:MK0021'); // 场内ETF
44
+ url.searchParams.set('fields', 'f12,f14,f2,f3,f4,f5,f6,f8');
45
+ url.searchParams.set('ut', 'bd1d9ddb04089700cf9c27f6f7426281');
46
+
47
+ const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
48
+ if (!resp.ok) throw new CliError('HTTP_ERROR', `etf failed: HTTP ${resp.status}`);
49
+ const data = await resp.json();
50
+ const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
51
+ if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no ETF data');
52
+
53
+ return diff.slice(0, limit).map((it, i) => ({
54
+ rank: i + 1,
55
+ code: it.f12,
56
+ name: it.f14,
57
+ price: it.f2,
58
+ changePercent: it.f3,
59
+ change: it.f4,
60
+ turnover: it.f6,
61
+ volume: it.f5,
62
+ turnoverRate: it.f8,
63
+ }));
64
+ },
65
+ });
@@ -0,0 +1,78 @@
1
+ // eastmoney holders — top-10 float shareholders of an A-share (F10 data).
2
+ //
3
+ // opencli eastmoney holders 600519
4
+ // opencli eastmoney holders sh600519 --limit 10
5
+
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { CliError } from '@jackwener/opencli/errors';
8
+
9
+ /**
10
+ * Convert a bare A-share symbol to eastmoney's SECUCODE form ("600519.SH").
11
+ * Accepts "600519", "sh600519", "sz000001", "bj430047", or full "600519.SH".
12
+ * @param {string} input
13
+ * @returns {string}
14
+ */
15
+ function toSecucode(input) {
16
+ const raw = String(input || '').trim().toUpperCase();
17
+ if (/^\d{6}\.(SH|SZ|BJ)$/.test(raw)) return raw;
18
+ const pref = raw.match(/^(SH|SZ|BJ)(\d{6})$/);
19
+ if (pref) return `${pref[2]}.${pref[1]}`;
20
+ if (/^\d{6}$/.test(raw)) {
21
+ if (/^(60|68|90|113|900)/.test(raw)) return `${raw}.SH`;
22
+ if (/^(4|8|920|83|87)/.test(raw)) return `${raw}.BJ`;
23
+ return `${raw}.SZ`;
24
+ }
25
+ throw new Error(`Unrecognized A-share symbol: ${input}`);
26
+ }
27
+
28
+ cli({
29
+ site: 'eastmoney',
30
+ name: 'holders',
31
+ description: '十大流通股东(A股 F10 数据)',
32
+ domain: 'datacenter-web.eastmoney.com',
33
+ strategy: Strategy.PUBLIC,
34
+ browser: false,
35
+ args: [
36
+ { name: 'symbol', required: true, positional: true, help: 'A股代码(600519 / sh600519 等)' },
37
+ { name: 'limit', type: 'int', default: 10, help: '返回股东数(默认十大流通股东)' },
38
+ ],
39
+ columns: ['rank', 'reportDate', 'name', 'holdNum', 'floatRatio', 'change'],
40
+ func: async (_page, args) => {
41
+ /** @type {string} */
42
+ let secucode;
43
+ try { secucode = toSecucode(args.symbol); }
44
+ catch (err) { throw new CliError('INVALID_ARGUMENT', `${err instanceof Error ? err.message : err}`); }
45
+ const limit = Math.max(1, Math.min(Number(args.limit) || 10, 50));
46
+
47
+ const url = new URL('https://datacenter-web.eastmoney.com/api/data/v1/get');
48
+ url.searchParams.set('sortColumns', 'END_DATE,HOLDER_RANK');
49
+ url.searchParams.set('sortTypes', '-1,1');
50
+ url.searchParams.set('pageSize', String(Math.max(limit, 10)));
51
+ url.searchParams.set('pageNumber', '1');
52
+ url.searchParams.set('reportName', 'RPT_F10_EH_FREEHOLDERS');
53
+ url.searchParams.set('columns', 'SECUCODE,SECURITY_CODE,END_DATE,HOLDER_RANK,HOLDER_NAME,HOLD_NUM,FREE_HOLDNUM_RATIO,HOLD_NUM_CHANGE');
54
+ url.searchParams.set('source', 'HSF10');
55
+ url.searchParams.set('client', 'PC');
56
+ url.searchParams.set('filter', `(SECUCODE="${secucode}")`);
57
+
58
+ const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
59
+ if (!resp.ok) throw new CliError('HTTP_ERROR', `holders failed: HTTP ${resp.status}`);
60
+ const data = await resp.json();
61
+ const rows = Array.isArray(data?.result?.data) ? data.result.data : [];
62
+ if (rows.length === 0) throw new CliError('NO_DATA', `No shareholder data for ${secucode}`);
63
+
64
+ // Only the most recent reporting period
65
+ const latest = String(rows[0].END_DATE || '').slice(0, 10);
66
+ return rows
67
+ .filter((it) => String(it.END_DATE || '').slice(0, 10) === latest)
68
+ .slice(0, limit)
69
+ .map((it) => ({
70
+ rank: it.HOLDER_RANK,
71
+ reportDate: latest,
72
+ name: it.HOLDER_NAME,
73
+ holdNum: it.HOLD_NUM,
74
+ floatRatio: it.FREE_HOLDNUM_RATIO,
75
+ change: it.HOLD_NUM_CHANGE,
76
+ }));
77
+ },
78
+ });
@@ -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
+ });