Skip to content

Commit 61b247f

Browse files
xiangzy1xzyjackwener
authored
feat(quark): add Quark Drive adapter (#858)
* feat(quark): add Quark Drive adapter (ls, mkdir, mv, rename, rm, save, share-tree) Browser-based adapter for Quark Cloud Drive (pan.quark.cn) using cookie strategy. Supports file browsing, folder management, and saving shared files with task polling for async operations. * fix(quark): address review feedback on adapter correctness and docs - ls: fix depth off-by-one (default 0 now lists target folder only) - save/mv: report success only after pollTask confirms completion; throw on timeout - save/mv: reject combining --to and --to-fid instead of silently preferring --to-fid - utils: check content-type before calling r.json() to handle non-JSON responses gracefully - tests: add quark graceful auth-failure E2E cases for all 7 commands - docs: add Browser Bridge extension to prerequisites; fix mkdir --parent example; expand command table with positional args; add Notes section explaining stoken flow * fix(quark): map auth failures and cover utils * fix(quark): reuse auth mapping for share-tree --------- Co-authored-by: xzy <xzy@mbp-m5.local> Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent ce559da commit 61b247f

15 files changed

Lines changed: 842 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
141141
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
142142
| **xianyu** | `search` `item` `chat` |
143143
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` |
144+
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` |
144145

145146
79+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
146147

README.zh-CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
147147
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
148148
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
149149
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
150+
| **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 浏览器 |
150151
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
151152
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
152153
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 浏览器 |

clis/quark/ls.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import type { IPage } from '@jackwener/opencli/types';
3+
import {
4+
findFolder,
5+
formatSize,
6+
listMyDrive,
7+
} from './utils.js';
8+
9+
interface DriveNode {
10+
name: string;
11+
fid: string;
12+
is_dir: boolean;
13+
size: string;
14+
path: string;
15+
children?: DriveNode[];
16+
}
17+
18+
async function buildTree(
19+
page: IPage,
20+
pdirFid: string,
21+
parentPath: string,
22+
depth: number,
23+
maxDepth: number,
24+
dirsOnly: boolean,
25+
): Promise<DriveNode[]> {
26+
if (depth > maxDepth) return [];
27+
28+
const files = await listMyDrive(page, pdirFid);
29+
const nodes: DriveNode[] = [];
30+
31+
for (const file of files) {
32+
if (dirsOnly && !file.dir) continue;
33+
34+
const path = parentPath ? `${parentPath}/${file.file_name}` : file.file_name;
35+
const node: DriveNode = {
36+
name: file.file_name,
37+
fid: file.fid,
38+
is_dir: file.dir,
39+
size: formatSize(file.size),
40+
path,
41+
};
42+
43+
if (file.dir && depth < maxDepth) {
44+
node.children = await buildTree(page, file.fid, path, depth + 1, maxDepth, dirsOnly);
45+
}
46+
47+
nodes.push(node);
48+
}
49+
50+
return nodes;
51+
}
52+
53+
function flattenTree(nodes: DriveNode[], level = 0): Record<string, unknown>[] {
54+
const result: Record<string, unknown>[] = [];
55+
const indent = ' '.repeat(level);
56+
57+
for (const node of nodes) {
58+
result.push({
59+
name: `${indent}${node.name}`,
60+
fid: node.fid,
61+
is_dir: node.is_dir,
62+
size: node.size,
63+
path: node.path,
64+
});
65+
if (node.children) {
66+
result.push(...flattenTree(node.children, level + 1));
67+
}
68+
}
69+
70+
return result;
71+
}
72+
73+
cli({
74+
site: 'quark',
75+
name: 'ls',
76+
description: 'List files in your Quark Drive',
77+
domain: 'pan.quark.cn',
78+
strategy: Strategy.COOKIE,
79+
args: [
80+
{ name: 'path', positional: true, default: '', help: 'Folder path to list (empty for root)' },
81+
{ name: 'depth', type: 'int', default: 0, help: 'Max depth to traverse' },
82+
{ name: 'dirs-only', type: 'boolean', default: false, help: 'Show directories only' },
83+
],
84+
columns: ['name', 'is_dir', 'size', 'fid', 'path'],
85+
func: async (page, kwargs) => {
86+
const path = (kwargs.path as string) ?? '';
87+
const depth = Math.max(0, (kwargs.depth as number) ?? 0);
88+
const dirsOnly = (kwargs['dirs-only'] as boolean) ?? false;
89+
90+
const rootFid = path ? await findFolder(page, path) : '0';
91+
const tree = await buildTree(page, rootFid, path, 0, depth, dirsOnly);
92+
return flattenTree(tree);
93+
},
94+
});

clis/quark/mkdir.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ArgumentError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
import type { IPage } from '@jackwener/opencli/types';
4+
import { DRIVE_API, apiPost, findFolder } from './utils.js';
5+
6+
interface MkdirResult {
7+
status: string;
8+
fid: string;
9+
name: string;
10+
}
11+
12+
cli({
13+
site: 'quark',
14+
name: 'mkdir',
15+
description: 'Create a folder in your Quark Drive',
16+
domain: 'pan.quark.cn',
17+
strategy: Strategy.COOKIE,
18+
defaultFormat: 'json',
19+
args: [
20+
{ name: 'name', required: true, positional: true, help: 'Folder name' },
21+
{ name: 'parent', help: 'Parent folder path (resolved by name)' },
22+
{ name: 'parent-fid', help: 'Parent folder fid (use directly)' },
23+
],
24+
func: async (page: IPage, kwargs: Record<string, unknown>): Promise<MkdirResult> => {
25+
const name = kwargs.name as string;
26+
if (!name.trim()) throw new ArgumentError('Folder name cannot be empty');
27+
28+
if (kwargs.parent && kwargs['parent-fid']) {
29+
throw new ArgumentError('Cannot use both --parent and --parent-fid');
30+
}
31+
32+
const parentFid = kwargs['parent-fid']
33+
? (kwargs['parent-fid'] as string)
34+
: kwargs.parent
35+
? await findFolder(page, kwargs.parent as string)
36+
: '0';
37+
38+
const data = await apiPost<{ fid: string }>(page, `${DRIVE_API}?pr=ucpro&fr=pc`, {
39+
pdir_fid: parentFid,
40+
file_name: name,
41+
dir_path: '',
42+
dir_init_lock: false,
43+
});
44+
45+
return { status: 'ok', fid: data.fid, name };
46+
},
47+
});

clis/quark/mv.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
import type { IPage } from '@jackwener/opencli/types';
4+
import { DRIVE_API, apiPost, findFolder, pollTask } from './utils.js';
5+
6+
interface MoveResult {
7+
status: string;
8+
count: number;
9+
destination: string;
10+
task_id: string;
11+
completed: boolean;
12+
}
13+
14+
cli({
15+
site: 'quark',
16+
name: 'mv',
17+
description: 'Move files to a folder in your Quark Drive',
18+
domain: 'pan.quark.cn',
19+
strategy: Strategy.COOKIE,
20+
defaultFormat: 'json',
21+
timeoutSeconds: 120,
22+
args: [
23+
{ name: 'fids', required: true, positional: true, help: 'File IDs to move (comma-separated)' },
24+
{ name: 'to', default: '', help: 'Destination folder path (required unless --to-fid is set)' },
25+
{ name: 'to-fid', default: '', help: 'Destination folder ID (overrides --to)' },
26+
],
27+
func: async (page: IPage, kwargs: Record<string, unknown>): Promise<MoveResult> => {
28+
const to = kwargs.to as string;
29+
const toFid = kwargs['to-fid'] as string;
30+
const fids = kwargs.fids as string;
31+
const fidList = [...new Set(fids.split(',').map(id => id.trim()).filter(Boolean))];
32+
if (fidList.length === 0) throw new ArgumentError('No fids provided');
33+
if (!to && !toFid) throw new ArgumentError('Either --to or --to-fid is required');
34+
if (to && toFid) throw new ArgumentError('Cannot use both --to and --to-fid');
35+
36+
const targetFid = toFid || await findFolder(page, to);
37+
const data = await apiPost<{ task_id: string }>(page, `${DRIVE_API}/move?pr=ucpro&fr=pc`, {
38+
filelist: fidList,
39+
to_pdir_fid: targetFid,
40+
});
41+
42+
const result: MoveResult = {
43+
status: 'pending',
44+
count: fidList.length,
45+
destination: to || toFid,
46+
task_id: data.task_id,
47+
completed: false,
48+
};
49+
50+
if (data.task_id) {
51+
const completed = await pollTask(page, data.task_id);
52+
result.completed = completed;
53+
result.status = completed ? 'ok' : 'error';
54+
if (!completed) throw new CommandExecutionError('quark: Move task timed out');
55+
} else {
56+
result.status = 'ok';
57+
result.completed = true;
58+
}
59+
60+
return result;
61+
},
62+
});

clis/quark/rename.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ArgumentError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
import type { IPage } from '@jackwener/opencli/types';
4+
import { DRIVE_API, apiPost } from './utils.js';
5+
6+
interface RenameResult {
7+
status: string;
8+
fid: string;
9+
new_name: string;
10+
}
11+
12+
cli({
13+
site: 'quark',
14+
name: 'rename',
15+
description: 'Rename a file in your Quark Drive',
16+
domain: 'pan.quark.cn',
17+
strategy: Strategy.COOKIE,
18+
defaultFormat: 'json',
19+
args: [
20+
{ name: 'fid', required: true, positional: true, help: 'File ID to rename' },
21+
{ name: 'name', required: true, help: 'New file name' },
22+
],
23+
func: async (page: IPage, kwargs: Record<string, unknown>): Promise<RenameResult> => {
24+
const fid = kwargs.fid as string;
25+
const name = kwargs.name as string;
26+
if (!name.trim()) throw new ArgumentError('New name cannot be empty');
27+
28+
await apiPost(page, `${DRIVE_API}/rename?pr=ucpro&fr=pc`, {
29+
fid,
30+
file_name: name,
31+
});
32+
33+
return { status: 'ok', fid, new_name: name };
34+
},
35+
});

clis/quark/rm.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ArgumentError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
import type { IPage } from '@jackwener/opencli/types';
4+
import { DRIVE_API, apiPost } from './utils.js';
5+
6+
interface DeleteResult {
7+
status: string;
8+
count: number;
9+
deleted_fids: string[];
10+
}
11+
12+
cli({
13+
site: 'quark',
14+
name: 'rm',
15+
description: 'Delete files from your Quark Drive',
16+
domain: 'pan.quark.cn',
17+
strategy: Strategy.COOKIE,
18+
defaultFormat: 'json',
19+
args: [
20+
{ name: 'fids', required: true, positional: true, help: 'File IDs to delete (comma-separated)' },
21+
],
22+
func: async (page: IPage, kwargs: Record<string, unknown>): Promise<DeleteResult> => {
23+
const fids = kwargs.fids as string;
24+
const fidList = [...new Set(fids.split(',').map(id => id.trim()).filter(Boolean))];
25+
if (fidList.length === 0) throw new ArgumentError('No fids provided');
26+
27+
await apiPost(page, `${DRIVE_API}/delete?pr=ucpro&fr=pc`, {
28+
filelist: fidList,
29+
});
30+
31+
return { status: 'ok', count: fidList.length, deleted_fids: fidList };
32+
},
33+
});

0 commit comments

Comments
 (0)