@jackwener/opencli 1.6.7 → 1.6.9

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 (122) hide show
  1. package/README.md +5 -1
  2. package/README.zh-CN.md +8 -3
  3. package/dist/clis/1688/assets.d.ts +42 -0
  4. package/dist/clis/1688/assets.js +204 -0
  5. package/dist/clis/1688/assets.test.d.ts +1 -0
  6. package/dist/clis/1688/assets.test.js +39 -0
  7. package/dist/clis/1688/download.d.ts +9 -0
  8. package/dist/clis/1688/download.js +76 -0
  9. package/dist/clis/1688/download.test.d.ts +1 -0
  10. package/dist/clis/1688/download.test.js +31 -0
  11. package/dist/clis/1688/shared.d.ts +10 -0
  12. package/dist/clis/1688/shared.js +43 -0
  13. package/dist/clis/jianyu/search.d.ts +14 -0
  14. package/dist/clis/jianyu/search.js +135 -0
  15. package/dist/clis/jianyu/search.test.d.ts +1 -0
  16. package/dist/clis/jianyu/search.test.js +23 -0
  17. package/dist/clis/linux-do/topic-content.d.ts +35 -0
  18. package/dist/clis/linux-do/topic-content.js +154 -0
  19. package/dist/clis/linux-do/topic-content.test.d.ts +1 -0
  20. package/dist/clis/linux-do/topic-content.test.js +59 -0
  21. package/dist/clis/linux-do/topic.yaml +1 -16
  22. package/dist/clis/quark/ls.d.ts +1 -0
  23. package/dist/clis/quark/ls.js +63 -0
  24. package/dist/clis/quark/mkdir.d.ts +1 -0
  25. package/dist/clis/quark/mkdir.js +36 -0
  26. package/dist/clis/quark/mv.d.ts +1 -0
  27. package/dist/clis/quark/mv.js +53 -0
  28. package/dist/clis/quark/rename.d.ts +1 -0
  29. package/dist/clis/quark/rename.js +26 -0
  30. package/dist/clis/quark/rm.d.ts +1 -0
  31. package/dist/clis/quark/rm.js +24 -0
  32. package/dist/clis/quark/save.d.ts +1 -0
  33. package/dist/clis/quark/save.js +80 -0
  34. package/dist/clis/quark/share-tree.d.ts +1 -0
  35. package/dist/clis/quark/share-tree.js +45 -0
  36. package/dist/clis/quark/utils.d.ts +50 -0
  37. package/dist/clis/quark/utils.js +146 -0
  38. package/dist/clis/quark/utils.test.d.ts +1 -0
  39. package/dist/clis/quark/utils.test.js +58 -0
  40. package/dist/clis/twitter/reply.js +3 -8
  41. package/dist/clis/twitter/reply.test.js +5 -5
  42. package/dist/clis/xiaohongshu/note.js +8 -3
  43. package/dist/clis/xiaohongshu/note.test.js +11 -0
  44. package/dist/clis/xueqiu/groups.yaml +23 -0
  45. package/dist/clis/xueqiu/kline.yaml +65 -0
  46. package/dist/clis/xueqiu/watchlist.yaml +9 -9
  47. package/dist/clis/zhihu/answer.d.ts +1 -0
  48. package/dist/clis/zhihu/answer.js +194 -0
  49. package/dist/clis/zhihu/answer.test.d.ts +1 -0
  50. package/dist/clis/zhihu/answer.test.js +81 -0
  51. package/dist/clis/zhihu/comment.d.ts +1 -0
  52. package/dist/clis/zhihu/comment.js +335 -0
  53. package/dist/clis/zhihu/comment.test.d.ts +1 -0
  54. package/dist/clis/zhihu/comment.test.js +54 -0
  55. package/dist/clis/zhihu/favorite.d.ts +1 -0
  56. package/dist/clis/zhihu/favorite.js +224 -0
  57. package/dist/clis/zhihu/favorite.test.d.ts +1 -0
  58. package/dist/clis/zhihu/favorite.test.js +196 -0
  59. package/dist/clis/zhihu/follow.d.ts +1 -0
  60. package/dist/clis/zhihu/follow.js +80 -0
  61. package/dist/clis/zhihu/follow.test.d.ts +1 -0
  62. package/dist/clis/zhihu/follow.test.js +45 -0
  63. package/dist/clis/zhihu/like.d.ts +1 -0
  64. package/dist/clis/zhihu/like.js +91 -0
  65. package/dist/clis/zhihu/like.test.d.ts +1 -0
  66. package/dist/clis/zhihu/like.test.js +64 -0
  67. package/dist/clis/zhihu/target.d.ts +24 -0
  68. package/dist/clis/zhihu/target.js +91 -0
  69. package/dist/clis/zhihu/target.test.d.ts +1 -0
  70. package/dist/clis/zhihu/target.test.js +77 -0
  71. package/dist/clis/zhihu/write-shared.d.ts +32 -0
  72. package/dist/clis/zhihu/write-shared.js +221 -0
  73. package/dist/clis/zhihu/write-shared.test.d.ts +1 -0
  74. package/dist/clis/zhihu/write-shared.test.js +175 -0
  75. package/dist/src/analysis.d.ts +2 -0
  76. package/dist/src/analysis.js +6 -0
  77. package/dist/src/browser/bridge.d.ts +2 -0
  78. package/dist/src/browser/bridge.js +30 -24
  79. package/dist/src/browser/cdp.js +96 -0
  80. package/dist/src/browser/daemon-client.d.ts +17 -8
  81. package/dist/src/browser/daemon-client.js +12 -13
  82. package/dist/src/browser/daemon-client.test.js +32 -25
  83. package/dist/src/browser/index.d.ts +2 -1
  84. package/dist/src/browser/index.js +1 -1
  85. package/dist/src/browser.test.js +2 -3
  86. package/dist/src/build-manifest.d.ts +3 -1
  87. package/dist/src/build-manifest.js +10 -7
  88. package/dist/src/build-manifest.test.js +8 -4
  89. package/dist/src/cli.d.ts +2 -1
  90. package/dist/src/cli.js +48 -46
  91. package/dist/src/clis/binance/commands.test.d.ts +1 -0
  92. package/dist/src/clis/binance/commands.test.js +54 -0
  93. package/dist/src/commanderAdapter.js +19 -6
  94. package/dist/src/commands/daemon.js +2 -10
  95. package/dist/src/diagnostic.d.ts +28 -2
  96. package/dist/src/diagnostic.js +263 -25
  97. package/dist/src/diagnostic.test.js +220 -1
  98. package/dist/src/discovery.js +7 -17
  99. package/dist/src/doctor.d.ts +2 -0
  100. package/dist/src/doctor.js +59 -31
  101. package/dist/src/doctor.test.js +89 -16
  102. package/dist/src/download/progress.js +7 -2
  103. package/dist/src/execution.js +1 -13
  104. package/dist/src/explore.d.ts +0 -2
  105. package/dist/src/explore.js +61 -38
  106. package/dist/src/extension-manifest-regression.test.js +0 -1
  107. package/dist/src/generate.d.ts +3 -6
  108. package/dist/src/generate.js +4 -8
  109. package/dist/src/package-paths.d.ts +8 -0
  110. package/dist/src/package-paths.js +41 -0
  111. package/dist/src/plugin-scaffold.js +1 -3
  112. package/dist/src/plugin.d.ts +2 -1
  113. package/dist/src/plugin.js +25 -8
  114. package/dist/src/plugin.test.js +16 -1
  115. package/dist/src/record.d.ts +1 -2
  116. package/dist/src/record.js +14 -52
  117. package/dist/src/synthesize.d.ts +0 -2
  118. package/dist/src/synthesize.js +8 -4
  119. package/package.json +3 -3
  120. package/dist/cli-manifest.json +0 -17250
  121. package/dist/src/browser/discover.d.ts +0 -15
  122. package/dist/src/browser/discover.js +0 -19
@@ -0,0 +1,24 @@
1
+ import { ArgumentError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { DRIVE_API, apiPost } from './utils.js';
4
+ cli({
5
+ site: 'quark',
6
+ name: 'rm',
7
+ description: 'Delete files from your Quark Drive',
8
+ domain: 'pan.quark.cn',
9
+ strategy: Strategy.COOKIE,
10
+ defaultFormat: 'json',
11
+ args: [
12
+ { name: 'fids', required: true, positional: true, help: 'File IDs to delete (comma-separated)' },
13
+ ],
14
+ func: async (page, kwargs) => {
15
+ const fids = kwargs.fids;
16
+ const fidList = [...new Set(fids.split(',').map(id => id.trim()).filter(Boolean))];
17
+ if (fidList.length === 0)
18
+ throw new ArgumentError('No fids provided');
19
+ await apiPost(page, `${DRIVE_API}/delete?pr=ucpro&fr=pc`, {
20
+ filelist: fidList,
21
+ });
22
+ return { status: 'ok', count: fidList.length, deleted_fids: fidList };
23
+ },
24
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { SHARE_API, extractPwdId, apiPost, getToken, pollTask, findFolder, } from './utils.js';
4
+ async function saveShare(page, pwdId, stoken, fidList, targetFid, saveAll) {
5
+ const data = await apiPost(page, `${SHARE_API}/save?pr=ucpro&fr=pc`, {
6
+ pwd_id: pwdId,
7
+ stoken,
8
+ pdir_fid: '0',
9
+ to_pdir_fid: targetFid,
10
+ fid_list: fidList,
11
+ pdir_save_all: saveAll,
12
+ scene: 'link',
13
+ });
14
+ return data.task_id;
15
+ }
16
+ cli({
17
+ site: 'quark',
18
+ name: 'save',
19
+ description: 'Save shared files to your Quark Drive',
20
+ domain: 'pan.quark.cn',
21
+ strategy: Strategy.COOKIE,
22
+ defaultFormat: 'json',
23
+ timeoutSeconds: 120,
24
+ args: [
25
+ { name: 'url', required: true, positional: true, help: 'Quark share URL or pwd_id' },
26
+ { name: 'to', default: '', help: 'Destination folder path' },
27
+ { name: 'to-fid', default: '', help: 'Destination folder ID (overrides --to)' },
28
+ { name: 'fids', default: '', help: 'File IDs to save (comma-separated, from share-tree). Omit to save all.' },
29
+ { name: 'stoken', default: '', help: 'Share token (from share-tree output, required with --fids)' },
30
+ { name: 'passcode', default: '', help: 'Share passcode (if required)' },
31
+ ],
32
+ func: async (page, kwargs) => {
33
+ const url = kwargs.url;
34
+ const to = kwargs.to;
35
+ const toFid = kwargs['to-fid'];
36
+ const fids = kwargs.fids;
37
+ const stokenArg = kwargs.stoken;
38
+ const passcode = kwargs.passcode;
39
+ if (!to && !toFid)
40
+ throw new ArgumentError('Either --to or --to-fid is required');
41
+ if (to && toFid)
42
+ throw new ArgumentError('Cannot use both --to and --to-fid');
43
+ const pwdId = extractPwdId(url);
44
+ const saveAll = !fids;
45
+ let stoken;
46
+ let fidList;
47
+ if (saveAll) {
48
+ stoken = stokenArg || await getToken(page, pwdId, passcode);
49
+ fidList = [];
50
+ }
51
+ else {
52
+ if (!stokenArg)
53
+ throw new ArgumentError('--stoken is required when using --fids');
54
+ stoken = stokenArg;
55
+ fidList = [...new Set(fids.split(',').map(id => id.trim()).filter(Boolean))];
56
+ }
57
+ const targetFid = toFid || await findFolder(page, to);
58
+ const taskId = await saveShare(page, pwdId, stoken, fidList, targetFid, saveAll);
59
+ const result = {
60
+ success: false,
61
+ task_id: taskId,
62
+ saved_to: to || toFid,
63
+ target_fid: targetFid,
64
+ ...(saveAll ? {} : { fids: fidList }),
65
+ };
66
+ if (taskId) {
67
+ const completed = await pollTask(page, taskId, (task) => {
68
+ result.save_count = task.save_as?.save_as_sum_num;
69
+ });
70
+ result.completed = completed;
71
+ result.success = completed;
72
+ if (!completed)
73
+ throw new CommandExecutionError('quark: Save task timed out');
74
+ }
75
+ else {
76
+ result.success = true;
77
+ }
78
+ return result;
79
+ },
80
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { extractPwdId, formatDate, getShareList, getToken, } from './utils.js';
3
+ async function buildTree(page, pwdId, stoken, pdirFid, depth, maxDepth) {
4
+ if (depth > maxDepth)
5
+ return [];
6
+ const files = await getShareList(page, pwdId, stoken, pdirFid, { sort: 'file_type:asc,file_name:asc' });
7
+ const nodes = [];
8
+ for (const file of files) {
9
+ const node = {
10
+ fid: file.fid,
11
+ name: file.file_name,
12
+ size: file.size,
13
+ is_dir: file.dir,
14
+ created_at: formatDate(file.created_at),
15
+ updated_at: formatDate(file.updated_at),
16
+ };
17
+ if (file.dir && depth < maxDepth) {
18
+ node.children = await buildTree(page, pwdId, stoken, file.fid, depth + 1, maxDepth);
19
+ }
20
+ nodes.push(node);
21
+ }
22
+ return nodes;
23
+ }
24
+ cli({
25
+ site: 'quark',
26
+ name: 'share-tree',
27
+ description: 'Get directory tree from Quark Drive share link as nested JSON',
28
+ domain: 'pan.quark.cn',
29
+ strategy: Strategy.COOKIE,
30
+ defaultFormat: 'json',
31
+ args: [
32
+ { name: 'url', required: true, positional: true, help: 'Quark share URL or pwd_id' },
33
+ { name: 'passcode', default: '', help: 'Share passcode (if required)' },
34
+ { name: 'depth', type: 'int', default: 10, help: 'Max directory depth' },
35
+ ],
36
+ func: async (page, kwargs) => {
37
+ const url = kwargs.url;
38
+ const passcode = kwargs.passcode || '';
39
+ const depth = kwargs.depth ?? 10;
40
+ const pwdId = extractPwdId(url);
41
+ const stoken = await getToken(page, pwdId, passcode);
42
+ const tree = await buildTree(page, pwdId, stoken, '0', 0, depth);
43
+ return { pwd_id: pwdId, stoken, tree };
44
+ },
45
+ });
@@ -0,0 +1,50 @@
1
+ import type { IPage } from '@jackwener/opencli/types';
2
+ export declare const SHARE_API = "https://drive-h.quark.cn/1/clouddrive/share/sharepage";
3
+ export declare const DRIVE_API = "https://drive-pc.quark.cn/1/clouddrive/file";
4
+ export declare const TASK_API = "https://drive-pc.quark.cn/1/clouddrive/task";
5
+ export interface ApiResponse<T = unknown> {
6
+ status: number;
7
+ code: number;
8
+ message: string;
9
+ data: T;
10
+ metadata?: {
11
+ _total?: number;
12
+ };
13
+ }
14
+ export interface ShareFile {
15
+ fid: string;
16
+ file_name: string;
17
+ size: number;
18
+ dir: boolean;
19
+ created_at: number;
20
+ updated_at: number;
21
+ }
22
+ export interface DriveFile {
23
+ fid: string;
24
+ file_name: string;
25
+ size: number;
26
+ dir: boolean;
27
+ }
28
+ export declare function extractPwdId(url: string): string;
29
+ export declare function fetchJson<T = unknown>(page: IPage, url: string, options?: {
30
+ method?: string;
31
+ body?: object;
32
+ }): Promise<ApiResponse<T>>;
33
+ export declare function apiGet<T = unknown>(page: IPage, url: string): Promise<T>;
34
+ export declare function apiPost<T = unknown>(page: IPage, url: string, body: object): Promise<T>;
35
+ export declare function getToken(page: IPage, pwdId: string, passcode?: string): Promise<string>;
36
+ export declare function getShareList(page: IPage, pwdId: string, stoken: string, pdirFid?: string, options?: {
37
+ sort?: string;
38
+ }): Promise<ShareFile[]>;
39
+ export declare function listMyDrive(page: IPage, pdirFid: string): Promise<DriveFile[]>;
40
+ export declare function findFolder(page: IPage, path: string): Promise<string>;
41
+ export declare function formatDate(ts: number): string;
42
+ export declare function formatSize(bytes: number): string;
43
+ export interface TaskStatus {
44
+ status: number;
45
+ save_as?: {
46
+ save_as_sum_num: number;
47
+ };
48
+ }
49
+ export declare function getTaskStatus(page: IPage, taskId: string): Promise<TaskStatus | null>;
50
+ export declare function pollTask(page: IPage, taskId: string, onDone?: (task: TaskStatus) => void, maxAttempts?: number, intervalMs?: number): Promise<boolean>;
@@ -0,0 +1,146 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, getErrorMessage } from '@jackwener/opencli/errors';
2
+ export const SHARE_API = 'https://drive-h.quark.cn/1/clouddrive/share/sharepage';
3
+ export const DRIVE_API = 'https://drive-pc.quark.cn/1/clouddrive/file';
4
+ export const TASK_API = 'https://drive-pc.quark.cn/1/clouddrive/task';
5
+ const QUARK_DOMAIN = 'pan.quark.cn';
6
+ const AUTH_HINT = 'Quark Drive requires a logged-in browser session';
7
+ function isAuthFailure(message, status) {
8
+ if (status === 401 || status === 403)
9
+ return true;
10
+ return /not logged in|login required|please log in|authentication required|unauthorized|forbidden|未登录|请先登录|需要登录|登录/.test(message.toLowerCase());
11
+ }
12
+ function getErrorStatus(error) {
13
+ if (!error || typeof error !== 'object' || !('status' in error))
14
+ return undefined;
15
+ const status = error.status;
16
+ return typeof status === 'number' ? status : undefined;
17
+ }
18
+ function unwrapApiData(resp, action) {
19
+ if (resp.status === 200)
20
+ return resp.data;
21
+ if (isAuthFailure(resp.message, resp.status)) {
22
+ throw new AuthRequiredError(QUARK_DOMAIN, AUTH_HINT);
23
+ }
24
+ throw new CommandExecutionError(`quark: ${action}: ${resp.message}`);
25
+ }
26
+ export function extractPwdId(url) {
27
+ const m = url.match(/\/s\/([a-zA-Z0-9]+)/);
28
+ if (m)
29
+ return m[1];
30
+ if (/^[a-zA-Z0-9]+$/.test(url))
31
+ return url;
32
+ throw new ArgumentError(`Invalid Quark share URL: ${url}`);
33
+ }
34
+ export async function fetchJson(page, url, options) {
35
+ const method = options?.method || 'GET';
36
+ const body = options?.body ? JSON.stringify(options.body) : undefined;
37
+ const js = `fetch(${JSON.stringify(url)}, {
38
+ method: ${JSON.stringify(method)},
39
+ headers: { 'Content-Type': 'application/json' },
40
+ credentials: 'include',
41
+ ${body ? `body: ${JSON.stringify(body)},` : ''}
42
+ }).then(async r => {
43
+ const ct = r.headers.get('content-type') || '';
44
+ if (!ct.includes('json')) {
45
+ const text = await r.text().catch(() => '');
46
+ throw Object.assign(new Error('Non-JSON response: ' + text.slice(0, 200)), { status: r.status });
47
+ }
48
+ return r.json();
49
+ })`;
50
+ try {
51
+ return await page.evaluate(js);
52
+ }
53
+ catch (error) {
54
+ if (isAuthFailure(getErrorMessage(error), getErrorStatus(error))) {
55
+ throw new AuthRequiredError(QUARK_DOMAIN, AUTH_HINT);
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+ export async function apiGet(page, url) {
61
+ const resp = await fetchJson(page, url);
62
+ return unwrapApiData(resp, 'API error');
63
+ }
64
+ export async function apiPost(page, url, body) {
65
+ const resp = await fetchJson(page, url, { method: 'POST', body });
66
+ return unwrapApiData(resp, 'API error');
67
+ }
68
+ export async function getToken(page, pwdId, passcode = '') {
69
+ const data = await fetchJson(page, `${SHARE_API}/token?pr=ucpro&fr=pc`, {
70
+ method: 'POST',
71
+ body: { pwd_id: pwdId, passcode, support_visit_limit_private_share: true },
72
+ });
73
+ return unwrapApiData(data, 'Failed to get token').stoken;
74
+ }
75
+ export async function getShareList(page, pwdId, stoken, pdirFid = '0', options) {
76
+ const allFiles = [];
77
+ let pageNum = 1;
78
+ let total = 0;
79
+ do {
80
+ const sortParam = options?.sort ? `&_sort=${options.sort}` : '';
81
+ const url = `${SHARE_API}/detail?pr=ucpro&fr=pc&ver=2&pwd_id=${pwdId}&stoken=${encodeURIComponent(stoken)}&pdir_fid=${pdirFid}&force=0&_page=${pageNum}&_size=200&_fetch_total=1${sortParam}`;
82
+ const data = await fetchJson(page, url);
83
+ const files = unwrapApiData(data, 'Failed to get share list')?.list || [];
84
+ allFiles.push(...files);
85
+ total = data.metadata?._total || 0;
86
+ pageNum++;
87
+ } while (allFiles.length < total);
88
+ return allFiles;
89
+ }
90
+ export async function listMyDrive(page, pdirFid) {
91
+ const allFiles = [];
92
+ let pageNum = 1;
93
+ let total = 0;
94
+ do {
95
+ const url = `${DRIVE_API}/sort?pr=ucpro&fr=pc&pdir_fid=${pdirFid}&_page=${pageNum}&_size=200&_fetch_total=1&_sort=file_type:asc,file_name:asc`;
96
+ const data = await fetchJson(page, url);
97
+ const files = unwrapApiData(data, 'Failed to list drive')?.list || [];
98
+ allFiles.push(...files);
99
+ total = data.metadata?._total || 0;
100
+ pageNum++;
101
+ } while (allFiles.length < total);
102
+ return allFiles;
103
+ }
104
+ export async function findFolder(page, path) {
105
+ const parts = path.split('/').filter(Boolean);
106
+ let currentFid = '0';
107
+ for (const part of parts) {
108
+ const files = await listMyDrive(page, currentFid);
109
+ const existing = files.find(f => f.dir && f.file_name === part);
110
+ if (existing) {
111
+ currentFid = existing.fid;
112
+ }
113
+ else {
114
+ throw new CommandExecutionError(`quark: Folder "${part}" not found in "${path}"`);
115
+ }
116
+ }
117
+ return currentFid;
118
+ }
119
+ export function formatDate(ts) {
120
+ if (!ts)
121
+ return '';
122
+ const d = new Date(ts);
123
+ return d.toISOString().replace('T', ' ').slice(0, 19);
124
+ }
125
+ export function formatSize(bytes) {
126
+ if (bytes <= 0)
127
+ return '0 B';
128
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
129
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
130
+ return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
131
+ }
132
+ export async function getTaskStatus(page, taskId) {
133
+ const url = `${TASK_API}?pr=ucpro&fr=pc&task_id=${taskId}&retry_index=0`;
134
+ return apiGet(page, url);
135
+ }
136
+ export async function pollTask(page, taskId, onDone, maxAttempts = 30, intervalMs = 500) {
137
+ for (let i = 0; i < maxAttempts; i++) {
138
+ await new Promise(r => setTimeout(r, intervalMs));
139
+ const task = await getTaskStatus(page, taskId);
140
+ if (task?.status === 2) {
141
+ onDone?.(task);
142
+ return true;
143
+ }
144
+ }
145
+ return false;
146
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { apiGet, apiPost, extractPwdId, getShareList, getToken } from './utils.js';
4
+ function makePage(evaluateImpl) {
5
+ return {
6
+ evaluate: vi.fn(evaluateImpl),
7
+ };
8
+ }
9
+ describe('quark utils', () => {
10
+ it('extractPwdId accepts share URLs and raw ids', () => {
11
+ expect(extractPwdId('https://pan.quark.cn/s/abc123')).toBe('abc123');
12
+ expect(extractPwdId('abc123')).toBe('abc123');
13
+ });
14
+ it('maps JSON auth failures to AuthRequiredError', async () => {
15
+ const page = makePage(async () => ({
16
+ status: 401,
17
+ code: 401,
18
+ message: '未登录',
19
+ data: null,
20
+ }));
21
+ await expect(apiGet(page, 'https://drive-pc.quark.cn/test')).rejects.toBeInstanceOf(AuthRequiredError);
22
+ });
23
+ it('maps non-JSON auth pages to AuthRequiredError', async () => {
24
+ const page = makePage(async () => {
25
+ const error = Object.assign(new Error('Non-JSON response: <html><title>登录</title></html>'), { status: 401 });
26
+ throw error;
27
+ });
28
+ await expect(apiPost(page, 'https://drive-pc.quark.cn/test', {})).rejects.toBeInstanceOf(AuthRequiredError);
29
+ });
30
+ it('keeps generic API failures as CommandExecutionError', async () => {
31
+ const page = makePage(async () => ({
32
+ status: 500,
33
+ code: 500,
34
+ message: 'server busy',
35
+ data: null,
36
+ }));
37
+ await expect(apiGet(page, 'https://drive-pc.quark.cn/test')).rejects.toBeInstanceOf(CommandExecutionError);
38
+ });
39
+ it('unwraps successful token responses', async () => {
40
+ const page = makePage(async () => ({
41
+ status: 200,
42
+ code: 0,
43
+ message: 'ok',
44
+ data: { stoken: 'token123' },
45
+ }));
46
+ await expect(getToken(page, 'abc123')).resolves.toBe('token123');
47
+ });
48
+ it('maps share-tree detail auth failures to AuthRequiredError', async () => {
49
+ const page = makePage(async () => ({
50
+ status: 401,
51
+ code: 401,
52
+ message: '请先登录',
53
+ data: null,
54
+ metadata: { _total: 0 },
55
+ }));
56
+ await expect(getShareList(page, 'abc123', 'token123')).rejects.toBeInstanceOf(AuthRequiredError);
57
+ });
58
+ });
@@ -240,18 +240,13 @@ cli({
240
240
  localImagePath = await downloadRemoteImage(kwargs['image-url']);
241
241
  cleanupDir = path.dirname(localImagePath);
242
242
  }
243
- // Dedicated composer is more reliable for image replies because the media
244
- // toolbar and file input are consistently present there.
243
+ // Dedicated composer is more reliable than the inline tweet page reply box.
244
+ await page.goto(buildReplyComposerUrl(kwargs.url), { waitUntil: 'load', settleMs: 2500 });
245
+ await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
245
246
  if (localImagePath) {
246
- await page.goto(buildReplyComposerUrl(kwargs.url));
247
- await page.wait({ selector: '[data-testid="tweetTextarea_0"]' });
248
247
  await page.wait({ selector: REPLY_FILE_INPUT_SELECTOR, timeout: 20 });
249
248
  await attachReplyImage(page, localImagePath);
250
249
  }
251
- else {
252
- await page.goto(kwargs.url);
253
- await page.wait({ selector: '[data-testid="primaryColumn"]' });
254
- }
255
250
  const result = await submitReply(page, kwargs.text);
256
251
  if (result.ok) {
257
252
  await page.wait(3); // Wait for network submission to complete
@@ -34,7 +34,7 @@ function createPageMock(evaluateResults, overrides = {}) {
34
34
  };
35
35
  }
36
36
  describe('twitter reply command', () => {
37
- it('keeps the text-only reply flow working', async () => {
37
+ it('uses the dedicated reply composer for text-only replies too', async () => {
38
38
  const cmd = getRegistry().get('twitter/reply');
39
39
  expect(cmd?.func).toBeTypeOf('function');
40
40
  const page = createPageMock([
@@ -44,8 +44,8 @@ describe('twitter reply command', () => {
44
44
  url: 'https://x.com/_kop6/status/2040254679301718161?s=20',
45
45
  text: 'text-only reply',
46
46
  });
47
- expect(page.goto).toHaveBeenCalledWith('https://x.com/_kop6/status/2040254679301718161?s=20');
48
- expect(page.wait).toHaveBeenCalledWith({ selector: '[data-testid="primaryColumn"]' });
47
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 });
48
+ expect(page.wait).toHaveBeenCalledWith({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
49
49
  expect(result).toEqual([
50
50
  {
51
51
  status: 'success',
@@ -72,8 +72,8 @@ describe('twitter reply command', () => {
72
72
  text: 'reply with image',
73
73
  image: imagePath,
74
74
  });
75
- expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161');
76
- expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]' });
75
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 });
76
+ expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
77
77
  expect(page.wait).toHaveBeenNthCalledWith(2, { selector: 'input[type="file"][data-testid="fileInput"]', timeout: 20 });
78
78
  expect(setFileInput).toHaveBeenCalledWith([imagePath], 'input[type="file"][data-testid="fileInput"]');
79
79
  expect(result).toEqual([
@@ -37,9 +37,14 @@ cli({
37
37
  const title = clean(document.querySelector('#detail-title, .title'))
38
38
  const desc = clean(document.querySelector('#detail-desc, .desc, .note-text'))
39
39
  const author = clean(document.querySelector('.username, .author-wrapper .name'))
40
- const likes = clean(document.querySelector('.like-wrapper .count'))
41
- const collects = clean(document.querySelector('.collect-wrapper .count'))
42
- const comments = clean(document.querySelector('.chat-wrapper .count'))
40
+ // Scope to .interact-container — the post's main interaction bar.
41
+ // Without scoping, .like-wrapper / .chat-wrapper also match each
42
+ // comment's like/reply buttons in the comment section, and
43
+ // querySelector returns the FIRST match (a comment's count, not the
44
+ // post's). The post's true counts live inside .interact-container.
45
+ const likes = clean(document.querySelector('.interact-container .like-wrapper .count'))
46
+ const collects = clean(document.querySelector('.interact-container .collect-wrapper .count'))
47
+ const comments = clean(document.querySelector('.interact-container .chat-wrapper .count'))
43
48
 
44
49
  // Try to extract tags/topics
45
50
  const tags = []
@@ -170,6 +170,17 @@ describe('xiaohongshu note', () => {
170
170
  expect(result.find((r) => r.field === 'collects').value).toBe('0');
171
171
  expect(result.find((r) => r.field === 'comments').value).toBe('0');
172
172
  });
173
+ it('scopes metric selectors to .interact-container to avoid matching comment like buttons', async () => {
174
+ const page = createPageMock({
175
+ loginWall: false, notFound: false,
176
+ title: 'Test', desc: '', author: 'Author', likes: '10', collects: '5', comments: '3', tags: [],
177
+ });
178
+ await command.func(page, { 'note-id': 'abc123' });
179
+ const evaluateScript = page.evaluate.mock.calls[0][0];
180
+ expect(evaluateScript).toContain('.interact-container .like-wrapper .count');
181
+ expect(evaluateScript).toContain('.interact-container .collect-wrapper .count');
182
+ expect(evaluateScript).toContain('.interact-container .chat-wrapper .count');
183
+ });
173
184
  it('omits tags row when no tags present', async () => {
174
185
  const page = createPageMock({
175
186
  loginWall: false, notFound: false,
@@ -0,0 +1,23 @@
1
+ site: xueqiu
2
+ name: groups
3
+ description: 获取雪球自选股分组列表(含模拟组合)
4
+ domain: xueqiu.com
5
+ browser: true
6
+
7
+ pipeline:
8
+ - navigate: https://xueqiu.com
9
+ - evaluate: |
10
+ (async () => {
11
+ const resp = await fetch('https://stock.xueqiu.com/v5/stock/portfolio/list.json?category=1&size=20', {credentials: 'include'});
12
+ if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
13
+ const d = await resp.json();
14
+ if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录');
15
+
16
+ return d.data.stocks.map(g => ({
17
+ pid: String(g.id),
18
+ name: g.name,
19
+ count: g.symbol_count || 0
20
+ }));
21
+ })()
22
+
23
+ columns: [pid, name, count]
@@ -0,0 +1,65 @@
1
+ site: xueqiu
2
+ name: kline
3
+ description: 获取雪球股票K线(历史行情)数据
4
+ domain: xueqiu.com
5
+ browser: true
6
+
7
+ args:
8
+ symbol:
9
+ positional: true
10
+ type: str
11
+ required: true
12
+ description: 股票代码,如 SH600519、SZ000858、AAPL
13
+ days:
14
+ type: int
15
+ default: 14
16
+ description: 回溯天数(默认14天)
17
+
18
+ pipeline:
19
+ - navigate: https://xueqiu.com
20
+
21
+ - evaluate: |
22
+ (async () => {
23
+ const symbol = (${{ args.symbol | json }} || '').toUpperCase();
24
+ const days = parseInt(${{ args.days | json }}) || 14;
25
+ if (!symbol) throw new Error('Missing argument: symbol');
26
+
27
+ // begin = now minus days (for count=-N, returns N items ending at begin)
28
+ const beginTs = Date.now();
29
+ const resp = await fetch('https://stock.xueqiu.com/v5/stock/chart/kline.json?symbol=' + encodeURIComponent(symbol) + '&begin=' + beginTs + '&period=day&type=before&count=-' + days, {credentials: 'include'});
30
+ if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
31
+ const d = await resp.json();
32
+
33
+ if (!d.data || !d.data.item || d.data.item.length === 0) return [];
34
+
35
+ const columns = d.data.column || [];
36
+ const items = d.data.item || [];
37
+ const colIdx = {};
38
+ columns.forEach((name, i) => { colIdx[name] = i; });
39
+
40
+ function fmt(v) { return v == null ? null : v; }
41
+
42
+ return items.map(row => ({
43
+ date: colIdx.timestamp != null ? new Date(row[colIdx.timestamp]).toISOString().split('T')[0] : null,
44
+ open: fmt(row[colIdx.open]),
45
+ high: fmt(row[colIdx.high]),
46
+ low: fmt(row[colIdx.low]),
47
+ close: fmt(row[colIdx.close]),
48
+ volume: fmt(row[colIdx.volume]),
49
+ amount: fmt(row[colIdx.amount]),
50
+ chg: fmt(row[colIdx.chg]),
51
+ percent: fmt(row[colIdx.percent]),
52
+ symbol: symbol
53
+ }));
54
+ })()
55
+
56
+ - map:
57
+ date: ${{ item.date }}
58
+ open: ${{ item.open }}
59
+ high: ${{ item.high }}
60
+ low: ${{ item.low }}
61
+ close: ${{ item.close }}
62
+ volume: ${{ item.volume }}
63
+ percent: ${{ item.percent }}
64
+
65
+ columns: [date, open, high, low, close, volume]
@@ -1,14 +1,14 @@
1
1
  site: xueqiu
2
2
  name: watchlist
3
- description: 获取雪球自选股列表
3
+ description: 获取雪球自选股/模拟组合股票列表
4
4
  domain: xueqiu.com
5
5
  browser: true
6
6
 
7
7
  args:
8
- category:
9
- type: str # using str to prevent parsing issues like 01
10
- default: "1"
11
- description: "分类:1=自选(默认) 2=持仓 3=关注"
8
+ pid:
9
+ type: str
10
+ default: "-1"
11
+ description: "分组ID:-1=全部(默认) -4=模拟 -5=沪深 -6=美股 -7=港股 -10=实盘 0=持仓(通过 xueqiu groups 获取)"
12
12
  limit:
13
13
  type: int
14
14
  default: 100
@@ -18,12 +18,12 @@ pipeline:
18
18
  - navigate: https://xueqiu.com
19
19
  - evaluate: |
20
20
  (async () => {
21
- const category = parseInt(${{ args.category | json }}) || 1;
22
- const resp = await fetch(`https://stock.xueqiu.com/v5/stock/portfolio/stock/list.json?size=100&category=${category}&pid=-1`, {credentials: 'include'});
21
+ const pid = ${{ args.pid | json }} || '-1';
22
+ const resp = await fetch(`https://stock.xueqiu.com/v5/stock/portfolio/stock/list.json?size=100&category=1&pid=${encodeURIComponent(pid)}`, {credentials: 'include'});
23
23
  if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
24
24
  const d = await resp.json();
25
25
  if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录');
26
-
26
+
27
27
  return d.data.stocks.map(s => ({
28
28
  symbol: s.symbol,
29
29
  name: s.name,
@@ -40,7 +40,7 @@ pipeline:
40
40
  name: ${{ item.name }}
41
41
  price: ${{ item.price }}
42
42
  changePercent: ${{ item.changePercent }}
43
-
43
+
44
44
  - limit: ${{ args.limit }}
45
45
 
46
46
  columns: [symbol, name, price, changePercent]
@@ -0,0 +1 @@
1
+ export {};