@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.
Files changed (93) hide show
  1. package/README.md +16 -16
  2. package/README.zh-CN.md +28 -15
  3. package/cli-manifest.json +547 -10
  4. package/clis/bilibili/favorite.js +18 -13
  5. package/clis/binance/depth.js +3 -4
  6. package/clis/boss/utils.js +2 -3
  7. package/clis/chatgpt-app/ax.js +6 -3
  8. package/clis/douban/search.js +1 -0
  9. package/clis/douban/search.test.js +11 -0
  10. package/clis/douban/subject.js +20 -93
  11. package/clis/douban/subject.test.js +11 -0
  12. package/clis/douban/utils.js +250 -8
  13. package/clis/douban/utils.test.js +179 -4
  14. package/clis/doubao/utils.js +319 -130
  15. package/clis/doubao/utils.test.js +241 -2
  16. package/clis/eastmoney/hot-rank.js +50 -0
  17. package/clis/eastmoney/hot-rank.test.js +59 -0
  18. package/clis/grok/image.test.ts +107 -0
  19. package/clis/grok/image.ts +356 -0
  20. package/clis/tdx/hot-rank.js +47 -0
  21. package/clis/tdx/hot-rank.test.js +59 -0
  22. package/clis/ths/hot-rank.js +49 -0
  23. package/clis/ths/hot-rank.test.js +64 -0
  24. package/clis/twitter/bookmarks.js +2 -1
  25. package/clis/uiverse/_shared.js +368 -0
  26. package/clis/uiverse/_shared.test.js +55 -0
  27. package/clis/uiverse/code.js +47 -0
  28. package/clis/uiverse/preview.js +71 -0
  29. package/clis/xiaohongshu/comments.js +2 -2
  30. package/clis/xiaohongshu/comments.test.js +46 -25
  31. package/clis/xiaohongshu/download.js +6 -7
  32. package/clis/xiaohongshu/download.test.js +17 -5
  33. package/clis/xiaohongshu/note-helpers.js +46 -12
  34. package/clis/xiaohongshu/note.js +3 -5
  35. package/clis/xiaohongshu/note.test.js +52 -25
  36. package/clis/xiaoyuzhou/auth.js +303 -0
  37. package/clis/xiaoyuzhou/auth.test.js +124 -0
  38. package/clis/xiaoyuzhou/download.js +49 -0
  39. package/clis/xiaoyuzhou/download.test.js +125 -0
  40. package/clis/xiaoyuzhou/transcript.js +76 -0
  41. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  42. package/clis/youtube/feed.js +120 -0
  43. package/clis/youtube/history.js +118 -0
  44. package/clis/youtube/like.js +62 -0
  45. package/clis/youtube/playlist.js +97 -0
  46. package/clis/youtube/subscribe.js +71 -0
  47. package/clis/youtube/subscriptions.js +57 -0
  48. package/clis/youtube/unlike.js +62 -0
  49. package/clis/youtube/unsubscribe.js +71 -0
  50. package/clis/youtube/utils.js +122 -0
  51. package/clis/youtube/utils.test.js +32 -1
  52. package/clis/youtube/watch-later.js +76 -0
  53. package/dist/src/browser/base-page.js +25 -5
  54. package/dist/src/browser/bridge.d.ts +2 -0
  55. package/dist/src/browser/bridge.js +51 -14
  56. package/dist/src/browser/cdp.js +1 -0
  57. package/dist/src/browser/daemon-client.d.ts +1 -0
  58. package/dist/src/browser/dom-snapshot.js +13 -1
  59. package/dist/src/browser/page.d.ts +4 -1
  60. package/dist/src/browser/page.js +48 -8
  61. package/dist/src/browser/page.test.js +61 -1
  62. package/dist/src/browser/target-errors.d.ts +23 -0
  63. package/dist/src/browser/target-errors.js +29 -0
  64. package/dist/src/browser/target-errors.test.d.ts +1 -0
  65. package/dist/src/browser/target-errors.test.js +61 -0
  66. package/dist/src/browser/target-resolver.d.ts +57 -0
  67. package/dist/src/browser/target-resolver.js +298 -0
  68. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  69. package/dist/src/browser/target-resolver.test.js +43 -0
  70. package/dist/src/browser.test.js +38 -1
  71. package/dist/src/cli.js +45 -37
  72. package/dist/src/commands/daemon.d.ts +4 -2
  73. package/dist/src/commands/daemon.js +22 -2
  74. package/dist/src/commands/daemon.test.js +65 -2
  75. package/dist/src/daemon.js +2 -0
  76. package/dist/src/doctor.d.ts +1 -0
  77. package/dist/src/doctor.js +32 -9
  78. package/dist/src/doctor.test.js +28 -12
  79. package/dist/src/external-clis.yaml +2 -2
  80. package/dist/src/logger.d.ts +2 -2
  81. package/dist/src/logger.js +3 -3
  82. package/dist/src/output.js +1 -5
  83. package/dist/src/output.test.js +0 -21
  84. package/dist/src/pipeline/steps/transform.js +1 -1
  85. package/dist/src/pipeline/template.d.ts +1 -0
  86. package/dist/src/pipeline/template.js +11 -3
  87. package/dist/src/pipeline/template.test.js +3 -0
  88. package/dist/src/pipeline/transform.test.js +14 -0
  89. package/dist/src/plugin.d.ts +7 -1
  90. package/dist/src/plugin.js +23 -1
  91. package/dist/src/plugin.test.js +15 -1
  92. package/dist/src/types.d.ts +1 -1
  93. package/package.json +1 -1
@@ -0,0 +1,356 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as crypto from 'node:crypto';
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import type { IPage } from '@jackwener/opencli/types';
6
+
7
+ const GROK_URL = 'https://grok.com/';
8
+ const NO_IMAGE_PREFIX = '[NO IMAGE]';
9
+ const BLOCKED_PREFIX = '[BLOCKED]';
10
+ const SESSION_HINT = 'Likely login/auth/challenge/session issue in the existing grok.com browser session.';
11
+
12
+ type SendResult = {
13
+ ok?: boolean;
14
+ msg?: string;
15
+ reason?: string;
16
+ detail?: string;
17
+ };
18
+
19
+ type BubbleImage = {
20
+ src: string;
21
+ w: number;
22
+ h: number;
23
+ };
24
+
25
+ type BubbleImageSet = BubbleImage[];
26
+
27
+ type FetchResult = {
28
+ ok: boolean;
29
+ base64?: string;
30
+ contentType?: string;
31
+ error?: string;
32
+ };
33
+
34
+ function normalizeBooleanFlag(value: unknown): boolean {
35
+ if (typeof value === 'boolean') return value;
36
+ const normalized = String(value ?? '').trim().toLowerCase();
37
+ return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
38
+ }
39
+
40
+ function dedupeBySrc(images: BubbleImage[]): BubbleImage[] {
41
+ const seen = new Set<string>();
42
+ const out: BubbleImage[] = [];
43
+ for (const img of images) {
44
+ if (!img.src || seen.has(img.src)) continue;
45
+ seen.add(img.src);
46
+ out.push(img);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function imagesSignature(images: BubbleImage[]): string {
52
+ return images.map(i => i.src).sort().join('|');
53
+ }
54
+
55
+ function extFromContentType(ct?: string): string {
56
+ if (!ct) return 'jpg';
57
+ if (ct.includes('png')) return 'png';
58
+ if (ct.includes('webp')) return 'webp';
59
+ if (ct.includes('gif')) return 'gif';
60
+ return 'jpg';
61
+ }
62
+
63
+ function buildFilename(src: string, ct?: string): string {
64
+ const ext = extFromContentType(ct);
65
+ const hash = crypto.createHash('sha1').update(src).digest('hex').slice(0, 12);
66
+ return `grok-${Date.now()}-${hash}.${ext}`;
67
+ }
68
+
69
+ /** Check whether the tab is already on grok.com (any path). */
70
+ async function isOnGrok(page: IPage): Promise<boolean> {
71
+ const url = await page.evaluate('window.location.href').catch(() => '');
72
+ if (typeof url !== 'string' || !url) return false;
73
+ try {
74
+ const hostname = new URL(url).hostname;
75
+ return hostname === 'grok.com' || hostname.endsWith('.grok.com');
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ async function tryStartFreshChat(page: IPage): Promise<void> {
82
+ await page.evaluate(`(() => {
83
+ const isVisible = (node) => {
84
+ if (!(node instanceof HTMLElement)) return false;
85
+ const rect = node.getBoundingClientRect();
86
+ const style = window.getComputedStyle(node);
87
+ return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
88
+ };
89
+ const candidates = Array.from(document.querySelectorAll('a, button')).filter(node => {
90
+ if (!isVisible(node)) return false;
91
+ const text = (node.textContent || '').trim().toLowerCase();
92
+ const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
93
+ const href = node.getAttribute('href') || '';
94
+ return text.includes('new chat')
95
+ || text.includes('new conversation')
96
+ || aria.includes('new chat')
97
+ || aria.includes('new conversation')
98
+ || href === '/';
99
+ });
100
+ const target = candidates[0];
101
+ if (target instanceof HTMLElement) target.click();
102
+ })()`);
103
+ }
104
+
105
+ async function sendPrompt(page: IPage, prompt: string): Promise<SendResult> {
106
+ const promptJson = JSON.stringify(prompt);
107
+ return page.evaluate(`(async () => {
108
+ try {
109
+ const waitFor = (ms) => new Promise(resolve => setTimeout(resolve, ms));
110
+ const composerSelector = '.ProseMirror[contenteditable="true"]';
111
+ const isVisibleEnabledSubmit = (node) => {
112
+ if (!(node instanceof HTMLButtonElement)) return false;
113
+ const rect = node.getBoundingClientRect();
114
+ const style = window.getComputedStyle(node);
115
+ return !node.disabled
116
+ && rect.width > 0
117
+ && rect.height > 0
118
+ && style.visibility !== 'hidden'
119
+ && style.display !== 'none';
120
+ };
121
+
122
+ let pm = null;
123
+ let box = null;
124
+ for (let attempt = 0; attempt < 12; attempt += 1) {
125
+ const composer = document.querySelector(composerSelector);
126
+ if (composer instanceof HTMLElement) {
127
+ pm = composer;
128
+ break;
129
+ }
130
+
131
+ const textarea = document.querySelector('textarea');
132
+ if (textarea instanceof HTMLTextAreaElement) {
133
+ box = textarea;
134
+ break;
135
+ }
136
+
137
+ await waitFor(1000);
138
+ }
139
+
140
+ // Prefer the ProseMirror composer when present (current grok.com UI).
141
+ if (pm && pm.editor && pm.editor.commands) {
142
+ try {
143
+ if (pm.editor.commands.clearContent) pm.editor.commands.clearContent();
144
+ pm.editor.commands.focus();
145
+ pm.editor.commands.insertContent(${promptJson});
146
+ for (let attempt = 0; attempt < 6; attempt += 1) {
147
+ const sbtn = Array.from(document.querySelectorAll('button[aria-label="Submit"], button[aria-label="\\u63d0\\u4ea4"]'))
148
+ .find(isVisibleEnabledSubmit);
149
+ if (sbtn) {
150
+ sbtn.click();
151
+ return { ok: true, msg: 'pm-submit' };
152
+ }
153
+ await waitFor(500);
154
+ }
155
+ } catch (e) { /* fall through to textarea */ }
156
+ }
157
+
158
+ // Fallback: legacy textarea composer.
159
+ if (!box) return { ok: false, msg: 'no composer (neither ProseMirror nor textarea)' };
160
+ box.focus(); box.value = '';
161
+ document.execCommand('selectAll');
162
+ document.execCommand('insertText', false, ${promptJson});
163
+ for (let attempt = 0; attempt < 6; attempt += 1) {
164
+ const btn = Array.from(document.querySelectorAll('button[aria-label="\\u63d0\\u4ea4"], button[aria-label="Submit"]'))
165
+ .find(isVisibleEnabledSubmit);
166
+ if (btn) {
167
+ btn.click();
168
+ return { ok: true, msg: 'clicked' };
169
+ }
170
+
171
+ const sub = Array.from(document.querySelectorAll('button[type="submit"]'))
172
+ .find(isVisibleEnabledSubmit);
173
+ if (sub) {
174
+ sub.click();
175
+ return { ok: true, msg: 'clicked-submit' };
176
+ }
177
+
178
+ await waitFor(500);
179
+ }
180
+ box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
181
+ return { ok: true, msg: 'enter' };
182
+ } catch (e) { return { ok: false, msg: e && e.toString ? e.toString() : String(e) }; }
183
+ })()`) as Promise<SendResult>;
184
+ }
185
+
186
+ /** Read <img> elements from all message bubbles so callers can filter by baseline. */
187
+ async function getBubbleImageSets(page: IPage): Promise<BubbleImageSet[]> {
188
+ const result = await page.evaluate(`(() => {
189
+ const bubbles = document.querySelectorAll('div.message-bubble, [data-testid="message-bubble"]');
190
+ return Array.from(bubbles).map(bubble => Array.from(bubble.querySelectorAll('img'))
191
+ .map(img => ({
192
+ src: img.currentSrc || img.src || '',
193
+ w: img.naturalWidth || img.width || 0,
194
+ h: img.naturalHeight || img.height || 0,
195
+ }))
196
+ .filter(i => i.src && /^https?:/.test(i.src))
197
+ // Ignore tiny UI/avatar images that may live in the bubble chrome.
198
+ .filter(i => (i.w === 0 || i.w >= 128) && (i.h === 0 || i.h >= 128)));
199
+ })()`) as BubbleImageSet[] | undefined;
200
+
201
+ const raw = Array.isArray(result) ? result : [];
202
+ return raw.map(dedupeBySrc);
203
+ }
204
+
205
+ function pickLatestImageCandidate(
206
+ bubbleImageSets: BubbleImageSet[],
207
+ baselineCount: number,
208
+ ): BubbleImage[] {
209
+ const freshSets = bubbleImageSets.slice(Math.max(0, baselineCount));
210
+ for (let i = freshSets.length - 1; i >= 0; i -= 1) {
211
+ if (freshSets[i].length) return freshSets[i];
212
+ }
213
+ return [];
214
+ }
215
+
216
+ // Download through the browser's fetch so grok.com cookies and referer are
217
+ // attached automatically — assets.grok.com is gated by Cloudflare and will
218
+ // refuse direct curl/node downloads.
219
+ async function fetchImageAsBase64(page: IPage, url: string): Promise<FetchResult> {
220
+ const urlJson = JSON.stringify(url);
221
+ return page.evaluate(`(async () => {
222
+ try {
223
+ const res = await fetch(${urlJson}, { credentials: 'include', referrer: 'https://grok.com/' });
224
+ if (!res.ok) return { ok: false, error: 'HTTP ' + res.status };
225
+ const blob = await res.blob();
226
+ const buf = await blob.arrayBuffer();
227
+ const bytes = new Uint8Array(buf);
228
+ let binary = '';
229
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
230
+ return { ok: true, base64: btoa(binary), contentType: blob.type || 'image/jpeg' };
231
+ } catch (e) { return { ok: false, error: e && e.message || String(e) }; }
232
+ })()`) as Promise<FetchResult>;
233
+ }
234
+
235
+ async function saveImages(
236
+ page: IPage,
237
+ images: BubbleImage[],
238
+ outDir: string,
239
+ ): Promise<Array<BubbleImage & { path: string }>> {
240
+ fs.mkdirSync(outDir, { recursive: true });
241
+ const results: Array<BubbleImage & { path: string }> = [];
242
+ for (const img of images) {
243
+ const fetched = await fetchImageAsBase64(page, img.src);
244
+ if (!fetched || !fetched.ok) {
245
+ results.push({ ...img, path: `[DOWNLOAD FAILED] ${fetched?.error || 'unknown'}` });
246
+ continue;
247
+ }
248
+ const filepath = path.join(outDir, buildFilename(img.src, fetched.contentType));
249
+ fs.writeFileSync(filepath, Buffer.from(fetched.base64 || '', 'base64'));
250
+ results.push({ ...img, path: filepath });
251
+ }
252
+ return results;
253
+ }
254
+
255
+ function toRow(img: BubbleImage, savedPath = '') {
256
+ return { url: img.src, width: img.w, height: img.h, path: savedPath };
257
+ }
258
+
259
+ export const imageCommand = cli({
260
+ site: 'grok',
261
+ name: 'image',
262
+ description: 'Generate images on grok.com and return image URLs',
263
+ domain: 'grok.com',
264
+ strategy: Strategy.COOKIE,
265
+ browser: true,
266
+ args: [
267
+ { name: 'prompt', positional: true, type: 'string', required: true, help: 'Image generation prompt' },
268
+ { name: 'timeout', type: 'int', default: 240, help: 'Max seconds to wait for the image (default: 240)' },
269
+ { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending (default: false)' },
270
+ { name: 'count', type: 'int', default: 1, help: 'Minimum images to wait for before returning (default: 1)' },
271
+ { name: 'out', type: 'string', default: '', help: 'Directory to save downloaded images (uses browser session to bypass auth)' },
272
+ ],
273
+ columns: ['url', 'width', 'height', 'path'],
274
+ func: async (page: IPage, kwargs: Record<string, any>) => {
275
+ const prompt = kwargs.prompt as string;
276
+ const timeoutMs = ((kwargs.timeout as number) || 240) * 1000;
277
+ const newChat = normalizeBooleanFlag(kwargs.new);
278
+ const minCount = Math.max(1, Number(kwargs.count || 1));
279
+ const outDir = (kwargs.out || '').toString().trim();
280
+
281
+ if (newChat) {
282
+ await page.goto(GROK_URL);
283
+ await page.wait(2);
284
+ await tryStartFreshChat(page);
285
+ await page.wait(2);
286
+ } else if (!(await isOnGrok(page))) {
287
+ await page.goto(GROK_URL);
288
+ await page.wait(3);
289
+ }
290
+
291
+ const baselineBubbleCount = (await getBubbleImageSets(page)).length;
292
+ const sendResult = await sendPrompt(page, prompt);
293
+ if (!sendResult || !sendResult.ok) {
294
+ return [{
295
+ url: `${BLOCKED_PREFIX} send failed: ${JSON.stringify(sendResult)}. ${SESSION_HINT}`,
296
+ width: 0,
297
+ height: 0,
298
+ path: '',
299
+ }];
300
+ }
301
+
302
+ const startTime = Date.now();
303
+ let lastSignature = '';
304
+ let stableCount = 0;
305
+ let lastImages: BubbleImage[] = [];
306
+
307
+ while (Date.now() - startTime < timeoutMs) {
308
+ await page.wait(3);
309
+ const bubbleImageSets = await getBubbleImageSets(page);
310
+ const images = pickLatestImageCandidate(bubbleImageSets, baselineBubbleCount);
311
+
312
+ if (images.length >= minCount) {
313
+ const signature = imagesSignature(images);
314
+ if (signature === lastSignature) {
315
+ stableCount += 1;
316
+ // Require two consecutive stable reads (~6s) before declaring done.
317
+ if (stableCount >= 2) {
318
+ if (outDir) {
319
+ const saved = await saveImages(page, images, outDir);
320
+ return saved.map(s => toRow(s, s.path));
321
+ }
322
+ return images.map(i => toRow(i));
323
+ }
324
+ } else {
325
+ stableCount = 0;
326
+ lastSignature = signature;
327
+ lastImages = images;
328
+ }
329
+ }
330
+ }
331
+
332
+ if (lastImages.length) {
333
+ if (outDir) {
334
+ const saved = await saveImages(page, lastImages, outDir);
335
+ return saved.map(s => toRow(s, s.path));
336
+ }
337
+ return lastImages.map(i => toRow(i));
338
+ }
339
+ return [{
340
+ url: `${NO_IMAGE_PREFIX} No image appeared within ${Math.round(timeoutMs / 1000)}s.`,
341
+ width: 0,
342
+ height: 0,
343
+ path: '',
344
+ }];
345
+ },
346
+ });
347
+
348
+ export const __test__ = {
349
+ normalizeBooleanFlag,
350
+ isOnGrok,
351
+ dedupeBySrc,
352
+ imagesSignature,
353
+ extFromContentType,
354
+ buildFilename,
355
+ pickLatestImageCandidate,
356
+ };
@@ -0,0 +1,47 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+
3
+ const TDX_HOT_URL = 'https://pul.tdx.com.cn/site/app/gzhbd/tdx-topsearch/page-main.html?pageName=page_topsearch&tabClickIndex=0&subtabIndex=0';
4
+
5
+ cli({
6
+ site: 'tdx',
7
+ name: 'hot-rank',
8
+ description: '通达信热搜榜',
9
+ domain: 'pul.tdx.com.cn',
10
+ strategy: Strategy.COOKIE,
11
+ navigateBefore: true,
12
+ args: [
13
+ { name: 'limit', type: 'int', default: 20, help: '返回数量' },
14
+ ],
15
+ columns: ['rank', 'symbol', 'name', 'changePercent', 'heat', 'tags'],
16
+ func: async (page, kwargs) => {
17
+ await page.goto(TDX_HOT_URL);
18
+ await page.wait({ timeout: 15000 });
19
+ const data = await page.evaluate(`
20
+ (() => {
21
+ const cleanText = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim();
22
+ const cells = document.querySelectorAll('div.top-cell[data-code]');
23
+ const results = [];
24
+ const seen = new Set();
25
+ cells.forEach((cell, idx) => {
26
+ const symbol = cell.getAttribute('data-code') || '';
27
+ const name = cell.getAttribute('data-name') || '';
28
+ if (!symbol || !name || seen.has(symbol)) return;
29
+ seen.add(symbol);
30
+ const tagEls = cell.querySelectorAll('div.tips-item.gnbk');
31
+ const tags = Array.from(tagEls).map(t => cleanText(t)).filter(Boolean).join(',');
32
+ results.push({
33
+ rank: idx + 1,
34
+ symbol,
35
+ name,
36
+ changePercent: cleanText(cell.querySelector('div.top-zf')),
37
+ heat: cleanText(cell.querySelector('div.hotN')),
38
+ tags,
39
+ });
40
+ });
41
+ return results;
42
+ })()
43
+ `);
44
+ if (!Array.isArray(data)) return [];
45
+ return data.slice(0, kwargs.limit);
46
+ },
47
+ });
@@ -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('tdx hot-rank command', () => {
6
+ it('registers the command with correct metadata', () => {
7
+ const command = getRegistry().get('tdx/hot-rank');
8
+ expect(command).toBeDefined();
9
+ expect(command).toMatchObject({
10
+ site: 'tdx',
11
+ name: 'hot-rank',
12
+ description: expect.stringContaining('通达信'),
13
+ domain: 'pul.tdx.com.cn',
14
+ navigateBefore: true,
15
+ });
16
+ expect(command.columns).toEqual(['rank', 'symbol', 'name', 'changePercent', 'heat', 'tags']);
17
+ });
18
+
19
+ it('returns hot stock data from the page', async () => {
20
+ const command = getRegistry().get('tdx/hot-rank');
21
+ const mockData = [
22
+ { rank: 1, symbol: '600519', name: '贵州茅台', changePercent: '+2.35%', heat: '1285', tags: '白酒', },
23
+ { rank: 2, symbol: '000001', name: '平安银行', changePercent: '-0.80%', heat: '856', tags: '银行', },
24
+ ];
25
+ const page = {
26
+ goto: vi.fn().mockResolvedValue(undefined),
27
+ wait: vi.fn().mockResolvedValue(undefined),
28
+ evaluate: vi.fn().mockResolvedValue(mockData),
29
+ };
30
+ const result = await command.func(page, { limit: 20 });
31
+ expect(result).toHaveLength(2);
32
+ expect(result[0]).toEqual(mockData[0]);
33
+ });
34
+
35
+ it('respects the limit parameter', async () => {
36
+ const command = getRegistry().get('tdx/hot-rank');
37
+ const mockData = Array.from({ length: 30 }, (_, i) => ({
38
+ rank: i + 1, symbol: `${i}`, name: `stock${i}`, changePercent: '0%', heat: '0', tags: '',
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('tdx/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,49 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+
3
+ const THS_HOT_URL = 'https://eq.10jqka.com.cn/webpage/ths-hot-list/index.html?showStatusBar=true';
4
+
5
+ cli({
6
+ site: 'ths',
7
+ name: 'hot-rank',
8
+ description: '同花顺热股榜',
9
+ domain: 'eq.10jqka.com.cn',
10
+ strategy: Strategy.COOKIE,
11
+ navigateBefore: true,
12
+ args: [
13
+ { name: 'limit', type: 'int', default: 20, help: '返回数量' },
14
+ ],
15
+ columns: ['rank', 'name', 'changePercent', 'heat', 'tags'],
16
+ func: async (page, kwargs) => {
17
+ await page.goto(THS_HOT_URL);
18
+ await page.wait({ timeout: 15000 });
19
+ const data = await page.evaluate(`
20
+ (() => {
21
+ const cleanText = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim();
22
+ const cards = document.querySelectorAll('div.pt-22.pb-24.bgc-white.border');
23
+ const results = [];
24
+ const seen = new Set();
25
+ cards.forEach((card, idx) => {
26
+ const row = card.querySelector('div.flex.bgc-white');
27
+ if (!row) return;
28
+ const nameEl = row.querySelector('span.ellipsis');
29
+ const name = cleanText(nameEl);
30
+ if (!name || seen.has(name)) return;
31
+ seen.add(name);
32
+ const tagEls = card.querySelectorAll('div.tag.PFSC-R');
33
+ const tags = Array.from(tagEls).map(t => cleanText(t)).filter(Boolean).join(',');
34
+ const rankEl = row.querySelector('div.THSMF-M.bold');
35
+ results.push({
36
+ rank: cleanText(rankEl) || String(idx + 1),
37
+ name,
38
+ changePercent: cleanText(row.querySelector('div.range')),
39
+ heat: cleanText(row.querySelector('div.col4 > span')),
40
+ tags,
41
+ });
42
+ });
43
+ return results;
44
+ })()
45
+ `);
46
+ if (!Array.isArray(data)) return [];
47
+ return data.slice(0, kwargs.limit);
48
+ },
49
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './hot-rank.js';
4
+
5
+ describe('ths hot-rank command', () => {
6
+ it('registers the command with correct metadata', () => {
7
+ const command = getRegistry().get('ths/hot-rank');
8
+ expect(command).toBeDefined();
9
+ expect(command).toMatchObject({
10
+ site: 'ths',
11
+ name: 'hot-rank',
12
+ description: expect.stringContaining('同花顺'),
13
+ domain: 'eq.10jqka.com.cn',
14
+ navigateBefore: true,
15
+ });
16
+ expect(command.columns).toEqual(['rank', 'name', 'changePercent', 'heat', 'tags']);
17
+ });
18
+
19
+ it('includes tags column', () => {
20
+ const command = getRegistry().get('ths/hot-rank');
21
+ expect(command.columns).toContain('tags');
22
+ });
23
+
24
+ it('returns hot stock data with tags field', async () => {
25
+ const command = getRegistry().get('ths/hot-rank');
26
+ const mockData = [
27
+ { rank: 1, name: '圣阳股份', changePercent: '+10.00%', heat: '28.5万', tags: '动力电池回收,钠离子电池' },
28
+ ];
29
+ const page = {
30
+ goto: vi.fn().mockResolvedValue(undefined),
31
+ wait: vi.fn().mockResolvedValue(undefined),
32
+ evaluate: vi.fn().mockResolvedValue(mockData),
33
+ };
34
+ const result = await command.func(page, { limit: 20 });
35
+ expect(result).toHaveLength(1);
36
+ expect(result[0].tags).toBe('动力电池回收,钠离子电池');
37
+ expect(result[0].name).toBe('圣阳股份');
38
+ });
39
+
40
+ it('respects the limit parameter', async () => {
41
+ const command = getRegistry().get('ths/hot-rank');
42
+ const mockData = Array.from({ length: 30 }, (_, i) => ({
43
+ rank: i + 1, name: `stock${i}`, changePercent: '0%', heat: '0', tags: '',
44
+ }));
45
+ const page = {
46
+ goto: vi.fn().mockResolvedValue(undefined),
47
+ wait: vi.fn().mockResolvedValue(undefined),
48
+ evaluate: vi.fn().mockResolvedValue(mockData),
49
+ };
50
+ const result = await command.func(page, { limit: 10 });
51
+ expect(result).toHaveLength(10);
52
+ });
53
+
54
+ it('returns empty array when evaluate returns non-array', async () => {
55
+ const command = getRegistry().get('ths/hot-rank');
56
+ const page = {
57
+ goto: vi.fn().mockResolvedValue(undefined),
58
+ wait: vi.fn().mockResolvedValue(undefined),
59
+ evaluate: vi.fn().mockResolvedValue(null),
60
+ };
61
+ const result = await command.func(page, { limit: 20 });
62
+ expect(result).toEqual([]);
63
+ });
64
+ });
@@ -60,6 +60,7 @@ function extractBookmarkTweet(result, seen) {
60
60
  text: noteText || legacy.full_text || '',
61
61
  likes: legacy.favorite_count || 0,
62
62
  retweets: legacy.retweet_count || 0,
63
+ bookmarks: legacy.bookmark_count || 0,
63
64
  created_at: legacy.created_at || '',
64
65
  url: `https://x.com/${screenName}/status/${tw.rest_id}`,
65
66
  };
@@ -106,7 +107,7 @@ cli({
106
107
  args: [
107
108
  { name: 'limit', type: 'int', default: 20 },
108
109
  ],
109
- columns: ['author', 'text', 'likes', 'url'],
110
+ columns: ['author', 'text', 'likes', 'retweets', 'bookmarks', 'url'],
110
111
  func: async (page, kwargs) => {
111
112
  const limit = kwargs.limit || 20;
112
113
  await page.goto('https://x.com');