@jackwener/opencli 1.7.4 → 1.7.6

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 (181) hide show
  1. package/README.md +76 -51
  2. package/README.zh-CN.md +78 -62
  3. package/cli-manifest.json +4558 -2979
  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/video.js +61 -0
  8. package/clis/bilibili/video.test.js +81 -0
  9. package/clis/deepseek/ask.js +94 -0
  10. package/clis/deepseek/ask.test.js +73 -0
  11. package/clis/deepseek/history.js +25 -0
  12. package/clis/deepseek/new.js +20 -0
  13. package/clis/deepseek/read.js +22 -0
  14. package/clis/deepseek/status.js +24 -0
  15. package/clis/deepseek/utils.js +291 -0
  16. package/clis/deepseek/utils.test.js +37 -0
  17. package/clis/eastmoney/_secid.js +78 -0
  18. package/clis/eastmoney/announcement.js +52 -0
  19. package/clis/eastmoney/convertible.js +73 -0
  20. package/clis/eastmoney/etf.js +65 -0
  21. package/clis/eastmoney/holders.js +78 -0
  22. package/clis/eastmoney/index-board.js +96 -0
  23. package/clis/eastmoney/kline.js +87 -0
  24. package/clis/eastmoney/kuaixun.js +54 -0
  25. package/clis/eastmoney/longhu.js +67 -0
  26. package/clis/eastmoney/money-flow.js +78 -0
  27. package/clis/eastmoney/northbound.js +57 -0
  28. package/clis/eastmoney/quote.js +107 -0
  29. package/clis/eastmoney/rank.js +94 -0
  30. package/clis/eastmoney/sectors.js +76 -0
  31. package/clis/google-scholar/search.js +58 -0
  32. package/clis/google-scholar/search.test.js +23 -0
  33. package/clis/gov-law/commands.test.js +39 -0
  34. package/clis/gov-law/recent.js +22 -0
  35. package/clis/gov-law/search.js +41 -0
  36. package/clis/gov-law/shared.js +51 -0
  37. package/clis/gov-policy/commands.test.js +27 -0
  38. package/clis/gov-policy/recent.js +47 -0
  39. package/clis/gov-policy/search.js +48 -0
  40. package/clis/jianyu/search.js +139 -3
  41. package/clis/jianyu/search.test.js +25 -0
  42. package/clis/jianyu/shared/procurement-detail.js +15 -0
  43. package/clis/jianyu/shared/procurement-detail.test.js +12 -0
  44. package/clis/nowcoder/companies.js +23 -0
  45. package/clis/nowcoder/creators.js +27 -0
  46. package/clis/nowcoder/detail.js +61 -0
  47. package/clis/nowcoder/experience.js +36 -0
  48. package/clis/nowcoder/hot.js +24 -0
  49. package/clis/nowcoder/jobs.js +21 -0
  50. package/clis/nowcoder/notifications.js +29 -0
  51. package/clis/nowcoder/papers.js +40 -0
  52. package/clis/nowcoder/practice.js +37 -0
  53. package/clis/nowcoder/recommend.js +30 -0
  54. package/clis/nowcoder/referral.js +39 -0
  55. package/clis/nowcoder/salary.js +40 -0
  56. package/clis/nowcoder/search.js +49 -0
  57. package/clis/nowcoder/suggest.js +33 -0
  58. package/clis/nowcoder/topics.js +27 -0
  59. package/clis/nowcoder/trending.js +25 -0
  60. package/clis/twitter/list-add.js +337 -0
  61. package/clis/twitter/list-add.test.js +15 -0
  62. package/clis/twitter/list-remove.js +297 -0
  63. package/clis/twitter/list-remove.test.js +14 -0
  64. package/clis/twitter/list-tweets.js +185 -0
  65. package/clis/twitter/list-tweets.test.js +108 -0
  66. package/clis/twitter/lists.js +134 -47
  67. package/clis/twitter/lists.test.js +105 -38
  68. package/clis/twitter/shared.js +7 -2
  69. package/clis/twitter/tweets.js +218 -0
  70. package/clis/twitter/tweets.test.js +125 -0
  71. package/clis/wanfang/search.js +66 -0
  72. package/clis/wanfang/search.test.js +23 -0
  73. package/clis/web/read.js +1 -1
  74. package/clis/weixin/download.js +3 -2
  75. package/clis/xiaohongshu/publish.js +149 -28
  76. package/clis/xiaohongshu/publish.test.js +319 -6
  77. package/clis/xiaoyuzhou/download.js +8 -4
  78. package/clis/xiaoyuzhou/download.test.js +23 -13
  79. package/clis/xiaoyuzhou/episode.js +9 -4
  80. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  81. package/clis/xiaoyuzhou/podcast.js +9 -4
  82. package/clis/xiaoyuzhou/utils.js +0 -40
  83. package/clis/xiaoyuzhou/utils.test.js +15 -75
  84. package/clis/youtube/channel.js +35 -0
  85. package/clis/zsxq/dynamics.js +1 -1
  86. package/clis/zsxq/utils.js +6 -3
  87. package/clis/zsxq/utils.test.js +31 -0
  88. package/dist/src/browser/base-page.d.ts +14 -4
  89. package/dist/src/browser/base-page.js +35 -25
  90. package/dist/src/browser/bridge.d.ts +1 -0
  91. package/dist/src/browser/bridge.js +1 -1
  92. package/dist/src/browser/cdp.d.ts +1 -0
  93. package/dist/src/browser/cdp.js +13 -4
  94. package/dist/src/browser/compound.d.ts +59 -0
  95. package/dist/src/browser/compound.js +112 -0
  96. package/dist/src/browser/compound.test.js +175 -0
  97. package/dist/src/browser/daemon-client.d.ts +6 -4
  98. package/dist/src/browser/daemon-client.js +6 -1
  99. package/dist/src/browser/daemon-client.test.js +40 -1
  100. package/dist/src/browser/dom-snapshot.d.ts +7 -0
  101. package/dist/src/browser/dom-snapshot.js +83 -5
  102. package/dist/src/browser/dom-snapshot.test.js +65 -0
  103. package/dist/src/browser/extract.d.ts +69 -0
  104. package/dist/src/browser/extract.js +132 -0
  105. package/dist/src/browser/extract.test.js +129 -0
  106. package/dist/src/browser/find.d.ts +76 -0
  107. package/dist/src/browser/find.js +179 -0
  108. package/dist/src/browser/find.test.js +120 -0
  109. package/dist/src/browser/html-tree.d.ts +75 -0
  110. package/dist/src/browser/html-tree.js +112 -0
  111. package/dist/src/browser/html-tree.test.d.ts +1 -0
  112. package/dist/src/browser/html-tree.test.js +181 -0
  113. package/dist/src/browser/network-cache.d.ts +48 -0
  114. package/dist/src/browser/network-cache.js +66 -0
  115. package/dist/src/browser/network-cache.test.d.ts +1 -0
  116. package/dist/src/browser/network-cache.test.js +58 -0
  117. package/dist/src/browser/network-key.d.ts +22 -0
  118. package/dist/src/browser/network-key.js +66 -0
  119. package/dist/src/browser/network-key.test.d.ts +1 -0
  120. package/dist/src/browser/network-key.test.js +49 -0
  121. package/dist/src/browser/page.d.ts +14 -4
  122. package/dist/src/browser/page.js +48 -7
  123. package/dist/src/browser/page.test.js +97 -0
  124. package/dist/src/browser/shape-filter.d.ts +52 -0
  125. package/dist/src/browser/shape-filter.js +101 -0
  126. package/dist/src/browser/shape-filter.test.d.ts +1 -0
  127. package/dist/src/browser/shape-filter.test.js +101 -0
  128. package/dist/src/browser/shape.d.ts +23 -0
  129. package/dist/src/browser/shape.js +95 -0
  130. package/dist/src/browser/shape.test.d.ts +1 -0
  131. package/dist/src/browser/shape.test.js +82 -0
  132. package/dist/src/browser/target-errors.d.ts +14 -1
  133. package/dist/src/browser/target-errors.js +13 -0
  134. package/dist/src/browser/target-errors.test.js +39 -6
  135. package/dist/src/browser/target-resolver.d.ts +57 -10
  136. package/dist/src/browser/target-resolver.js +195 -75
  137. package/dist/src/browser/target-resolver.test.js +80 -5
  138. package/dist/src/cli.js +849 -267
  139. package/dist/src/cli.test.js +961 -90
  140. package/dist/src/commanderAdapter.d.ts +0 -1
  141. package/dist/src/commanderAdapter.js +2 -16
  142. package/dist/src/commanderAdapter.test.js +1 -1
  143. package/dist/src/completion-shared.js +2 -5
  144. package/dist/src/daemon.js +8 -0
  145. package/dist/src/download/article-download.d.ts +1 -0
  146. package/dist/src/download/article-download.js +3 -0
  147. package/dist/src/download/article-download.test.d.ts +1 -0
  148. package/dist/src/download/article-download.test.js +39 -0
  149. package/dist/src/execution.js +7 -2
  150. package/dist/src/execution.test.js +54 -0
  151. package/dist/src/main.js +16 -0
  152. package/dist/src/plugin.d.ts +1 -8
  153. package/dist/src/plugin.js +1 -27
  154. package/dist/src/plugin.test.js +1 -59
  155. package/dist/src/registry.d.ts +1 -0
  156. package/dist/src/registry.js +3 -2
  157. package/dist/src/registry.test.js +22 -0
  158. package/dist/src/types.d.ts +32 -8
  159. package/package.json +1 -1
  160. package/clis/twitter/lists-parser.js +0 -77
  161. package/clis/twitter/lists.d.ts +0 -5
  162. package/dist/src/cascade.d.ts +0 -46
  163. package/dist/src/cascade.js +0 -135
  164. package/dist/src/explore.d.ts +0 -99
  165. package/dist/src/explore.js +0 -402
  166. package/dist/src/generate-verified.d.ts +0 -105
  167. package/dist/src/generate-verified.js +0 -696
  168. package/dist/src/generate-verified.test.js +0 -925
  169. package/dist/src/generate.d.ts +0 -46
  170. package/dist/src/generate.js +0 -117
  171. package/dist/src/record.d.ts +0 -96
  172. package/dist/src/record.js +0 -657
  173. package/dist/src/record.test.js +0 -293
  174. package/dist/src/skill-generate.d.ts +0 -30
  175. package/dist/src/skill-generate.js +0 -75
  176. package/dist/src/skill-generate.test.js +0 -173
  177. package/dist/src/synthesize.d.ts +0 -97
  178. package/dist/src/synthesize.js +0 -208
  179. /package/dist/src/{generate-verified.test.d.ts → browser/compound.test.d.ts} +0 -0
  180. /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
  181. /package/dist/src/{skill-generate.test.d.ts → browser/find.test.d.ts} +0 -0
@@ -0,0 +1,291 @@
1
+ export const DEEPSEEK_DOMAIN = 'chat.deepseek.com';
2
+ export const DEEPSEEK_URL = 'https://chat.deepseek.com/';
3
+ export const TEXTAREA_SELECTOR = 'textarea[placeholder*="DeepSeek"]';
4
+ export const MESSAGE_SELECTOR = '.ds-message';
5
+
6
+ export async function isOnDeepSeek(page) {
7
+ const url = await page.evaluate('window.location.href').catch(() => '');
8
+ if (typeof url !== 'string' || !url) return false;
9
+ try {
10
+ const h = new URL(url).hostname;
11
+ return h === 'deepseek.com' || h.endsWith('.deepseek.com');
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export async function ensureOnDeepSeek(page) {
18
+ if (!(await isOnDeepSeek(page))) {
19
+ await page.goto(DEEPSEEK_URL);
20
+ await page.wait(3);
21
+ }
22
+ }
23
+
24
+ export async function getPageState(page) {
25
+ return page.evaluate(`(() => {
26
+ const url = window.location.href;
27
+ const title = document.title;
28
+ const textarea = document.querySelector('${TEXTAREA_SELECTOR}');
29
+ const avatar = document.querySelector('img[src*="user-avatar"]');
30
+ return {
31
+ url,
32
+ title,
33
+ hasTextarea: !!textarea,
34
+ isLoggedIn: !!avatar,
35
+ };
36
+ })()`);
37
+ }
38
+
39
+ export async function selectModel(page, modelName) {
40
+ return page.evaluate(`(() => {
41
+ const radios = document.querySelectorAll('div[role="radio"]');
42
+ for (const radio of radios) {
43
+ const span = radio.querySelector('span');
44
+ if (span && span.textContent.trim().toLowerCase() === '${modelName}'.toLowerCase()) {
45
+ const alreadySelected = radio.getAttribute('aria-checked') === 'true';
46
+ if (!alreadySelected) radio.click();
47
+ return { ok: true, toggled: !alreadySelected };
48
+ }
49
+ }
50
+ return { ok: false };
51
+ })()`);
52
+ }
53
+
54
+ export async function setFeature(page, featureName, enabled) {
55
+ return page.evaluate(`(() => {
56
+ const btns = document.querySelectorAll('div[role="button"]');
57
+ for (const btn of btns) {
58
+ const span = btn.querySelector('span');
59
+ if (span && span.textContent.trim() === '${featureName}') {
60
+ const isActive = btn.classList.contains('ds-toggle-button--selected');
61
+ if (${enabled} !== isActive) btn.click();
62
+ return { ok: true, toggled: ${enabled} !== isActive };
63
+ }
64
+ }
65
+ return { ok: false };
66
+ })()`);
67
+ }
68
+
69
+ export async function sendMessage(page, prompt) {
70
+ const promptJson = JSON.stringify(prompt);
71
+ return page.evaluate(`(async () => {
72
+ const box = document.querySelector('${TEXTAREA_SELECTOR}');
73
+ if (!box) return { ok: false, reason: 'textarea not found' };
74
+
75
+ box.focus();
76
+ box.value = '';
77
+ document.execCommand('selectAll');
78
+ document.execCommand('insertText', false, ${promptJson});
79
+ await new Promise(r => setTimeout(r, 800));
80
+
81
+ const btns = document.querySelectorAll('div[role="button"]');
82
+ for (const btn of btns) {
83
+ if (btn.getAttribute('aria-disabled') === 'false') {
84
+ const svgs = btn.querySelectorAll('svg');
85
+ if (svgs.length > 0 && btn.closest('div')?.querySelector('textarea')) {
86
+ btn.click();
87
+ return { ok: true };
88
+ }
89
+ }
90
+ }
91
+
92
+ box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
93
+ return { ok: true, method: 'enter' };
94
+ })()`);
95
+ }
96
+
97
+ export async function getBubbleCount(page) {
98
+ const count = await page.evaluate(`(() => {
99
+ return document.querySelectorAll('${MESSAGE_SELECTOR}').length;
100
+ })()`);
101
+ return count || 0;
102
+ }
103
+
104
+ export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
105
+ const startTime = Date.now();
106
+ let lastText = '';
107
+ let stableCount = 0;
108
+
109
+ while (Date.now() - startTime < timeoutMs) {
110
+ await page.wait(3);
111
+
112
+ let result;
113
+ try {
114
+ result = await page.evaluate(`(() => {
115
+ const bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}');
116
+ const texts = Array.from(bubbles).map(b => (b.innerText || '').trim()).filter(Boolean);
117
+ return { count: texts.length, last: texts[texts.length - 1] || '' };
118
+ })()`);
119
+ } catch {
120
+ continue;
121
+ }
122
+
123
+ if (!result) continue;
124
+
125
+ const candidate = result.last;
126
+ if (candidate && result.count > baselineCount && candidate !== prompt.trim()) {
127
+ if (candidate === lastText) {
128
+ stableCount++;
129
+ if (stableCount >= 3) return candidate;
130
+ } else {
131
+ stableCount = 0;
132
+ }
133
+ lastText = candidate;
134
+ }
135
+ }
136
+
137
+ return lastText || null;
138
+ }
139
+
140
+ export async function getVisibleMessages(page) {
141
+ const result = await page.evaluate(`(() => {
142
+ const msgs = document.querySelectorAll('${MESSAGE_SELECTOR}');
143
+ return Array.from(msgs).map(m => {
144
+ // User messages carry an extra hash-class alongside ds-message
145
+ const isUser = m.className.split(/\\s+/).length > 2;
146
+ return {
147
+ Role: isUser ? 'user' : 'assistant',
148
+ Text: (m.innerText || '').trim(),
149
+ };
150
+ }).filter(m => m.Text);
151
+ })()`);
152
+ return Array.isArray(result) ? result : [];
153
+ }
154
+
155
+ export async function getConversationList(page) {
156
+ await ensureOnDeepSeek(page);
157
+ // Expand sidebar if collapsed
158
+ await page.evaluate(`(() => {
159
+ if (document.querySelectorAll('a[href*="/a/chat/s/"]').length === 0) {
160
+ const btn = document.querySelector('div[tabindex="0"][role="button"]');
161
+ if (btn) btn.click();
162
+ }
163
+ })()`);
164
+ for (let attempt = 0; attempt < 5; attempt++) {
165
+ await page.wait(2);
166
+ const items = await page.evaluate(`(() => {
167
+ const items = [];
168
+ const links = document.querySelectorAll('a[href*="/a/chat/s/"]');
169
+ links.forEach((link, i) => {
170
+ const titleEl = link.querySelector('div');
171
+ const title = titleEl ? titleEl.textContent.trim() : '';
172
+ const href = link.getAttribute('href') || '';
173
+ const idMatch = href.match(/\\/s\\/([a-f0-9-]+)/);
174
+ items.push({
175
+ Index: i + 1,
176
+ Id: idMatch ? idMatch[1] : href,
177
+ Title: title || '(untitled)',
178
+ Url: 'https://chat.deepseek.com' + href,
179
+ });
180
+ });
181
+ return items;
182
+ })()`);
183
+ if (Array.isArray(items) && items.length > 0) return items;
184
+ }
185
+ return [];
186
+ }
187
+
188
+ async function waitForFilePreview(page, fileName) {
189
+ for (let attempt = 0; attempt < 8; attempt++) {
190
+ await page.wait(2);
191
+ const ready = await page.evaluate(`(() => {
192
+ const name = ${JSON.stringify(fileName)};
193
+ return Array.from(document.querySelectorAll('div'))
194
+ .some((el) => el.children.length === 0 && (el.textContent || '').trim() === name);
195
+ })()`);
196
+ if (ready) return true;
197
+ }
198
+ return false;
199
+ }
200
+
201
+ export async function sendWithFile(page, filePath, prompt) {
202
+ const fs = await import('node:fs');
203
+ const path = await import('node:path');
204
+ const absPath = path.default.resolve(filePath);
205
+
206
+ if (!fs.default.existsSync(absPath)) {
207
+ return { ok: false, reason: `File not found: ${absPath}` };
208
+ }
209
+
210
+ const stats = fs.default.statSync(absPath);
211
+ if (stats.size > 100 * 1024 * 1024) {
212
+ return { ok: false, reason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB). Max: 100 MB` };
213
+ }
214
+
215
+ const fileName = path.default.basename(absPath);
216
+
217
+ // Collapse sidebar to keep DOM simple for send button matching
218
+ await page.evaluate(`(() => {
219
+ if (document.querySelectorAll('a[href*="/a/chat/s/"]').length > 0) {
220
+ const btn = document.querySelector('div[tabindex="0"][role="button"]');
221
+ if (btn) btn.click();
222
+ }
223
+ })()`);
224
+ await page.wait(0.5);
225
+
226
+ let uploaded = false;
227
+ if (page.setFileInput) {
228
+ try {
229
+ await page.setFileInput([absPath], 'input[type="file"]');
230
+ uploaded = true;
231
+ } catch (err) {
232
+ const msg = String(err?.message || err);
233
+ if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
234
+ throw err;
235
+ }
236
+ }
237
+ }
238
+
239
+ if (!uploaded) {
240
+ const content = fs.default.readFileSync(absPath);
241
+ const base64 = content.toString('base64');
242
+ const fallbackResult = await page.evaluate(`(async () => {
243
+ var binary = atob('${base64}');
244
+ var bytes = new Uint8Array(binary.length);
245
+ for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
246
+
247
+ var file = new File([bytes], ${JSON.stringify(fileName)});
248
+ var dt = new DataTransfer();
249
+ dt.items.add(file);
250
+
251
+ var inp = document.querySelector('input[type="file"]');
252
+ if (!inp) return { ok: false, reason: 'file input not found' };
253
+
254
+ var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
255
+ if (!propsKey || typeof inp[propsKey].onChange !== 'function') {
256
+ return { ok: false, reason: 'React onChange not found' };
257
+ }
258
+
259
+ inp.files = dt.files;
260
+ inp[propsKey].onChange({ target: { files: dt.files } });
261
+ return { ok: true };
262
+ })()`);
263
+ if (fallbackResult && !fallbackResult.ok) return fallbackResult;
264
+ }
265
+
266
+ const ready = await waitForFilePreview(page, fileName);
267
+ if (!ready) return { ok: false, reason: 'file preview did not appear' };
268
+
269
+ return sendMessage(page, prompt);
270
+ }
271
+
272
+ // Retries on CDP "Promise was collected" errors caused by DeepSeek's SPA router transitions.
273
+ export async function withRetry(fn, retries = 2) {
274
+ for (let i = 0; i <= retries; i++) {
275
+ try {
276
+ return await fn();
277
+ } catch (err) {
278
+ const msg = String(err?.message || err);
279
+ if (i < retries && msg.includes('Promise was collected')) {
280
+ await new Promise(r => setTimeout(r, 2000));
281
+ continue;
282
+ }
283
+ throw err;
284
+ }
285
+ }
286
+ }
287
+
288
+ export function parseBoolFlag(value) {
289
+ if (typeof value === 'boolean') return value;
290
+ return String(value ?? '').trim().toLowerCase() === 'true';
291
+ }
@@ -0,0 +1,37 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { sendWithFile } from './utils.js';
6
+
7
+ describe('deepseek sendWithFile', () => {
8
+ const tempDirs = [];
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ while (tempDirs.length) {
13
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
14
+ }
15
+ });
16
+
17
+ it('prefers page.setFileInput over base64-in-evaluate when supported', async () => {
18
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
19
+ tempDirs.push(dir);
20
+ const filePath = path.join(dir, 'report.txt');
21
+ fs.writeFileSync(filePath, 'hello');
22
+
23
+ const page = {
24
+ setFileInput: vi.fn().mockResolvedValue(undefined),
25
+ wait: vi.fn().mockResolvedValue(undefined),
26
+ evaluate: vi.fn()
27
+ .mockResolvedValueOnce(undefined)
28
+ .mockResolvedValueOnce(true)
29
+ .mockResolvedValueOnce({ ok: true }),
30
+ };
31
+
32
+ const result = await sendWithFile(page, filePath, 'summarize this');
33
+
34
+ expect(result).toEqual({ ok: true });
35
+ expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]');
36
+ });
37
+ });
@@ -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
+ });