@jackwener/opencli 1.7.5 → 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.
- package/README.md +5 -2
- package/README.zh-CN.md +5 -2
- package/cli-manifest.json +77 -1
- package/clis/bilibili/video.js +61 -0
- package/clis/bilibili/video.test.js +81 -0
- package/clis/deepseek/ask.js +21 -1
- package/clis/deepseek/ask.test.js +73 -0
- package/clis/deepseek/utils.js +84 -1
- package/clis/deepseek/utils.test.js +37 -0
- package/clis/jianyu/search.js +139 -3
- package/clis/jianyu/search.test.js +25 -0
- package/clis/jianyu/shared/procurement-detail.js +15 -0
- package/clis/jianyu/shared/procurement-detail.test.js +12 -0
- package/clis/twitter/shared.js +7 -2
- package/clis/twitter/tweets.js +218 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/youtube/channel.js +35 -0
- package/dist/src/browser/base-page.d.ts +13 -3
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +12 -3
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.d.ts +1 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +76 -3
- package/dist/src/browser/dom-snapshot.test.js +65 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.d.ts +1 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +76 -0
- package/dist/src/browser/find.js +179 -0
- package/dist/src/browser/find.test.d.ts +1 -0
- package/dist/src/browser/find.test.js +120 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/network-cache.d.ts +48 -0
- package/dist/src/browser/network-cache.js +66 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +58 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/target-errors.d.ts +14 -1
- package/dist/src/browser/target-errors.js +13 -0
- package/dist/src/browser/target-errors.test.js +39 -6
- package/dist/src/browser/target-resolver.d.ts +57 -10
- package/dist/src/browser/target-resolver.js +195 -75
- package/dist/src/browser/target-resolver.test.js +80 -5
- package/dist/src/cli.js +630 -125
- package/dist/src/cli.test.js +794 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/main.js +16 -0
- package/dist/src/types.d.ts +18 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -157,7 +157,8 @@ OpenCLI is not only for websites. It can also:
|
|
|
157
157
|
| Variable | Default | Description |
|
|
158
158
|
|----------|---------|-------------|
|
|
159
159
|
| `OPENCLI_DAEMON_PORT` | `19825` | HTTP port for the daemon-extension bridge |
|
|
160
|
-
| `OPENCLI_WINDOW_FOCUSED` | `false` | Set to `1` to open automation windows in the foreground (useful for debugging) |
|
|
160
|
+
| `OPENCLI_WINDOW_FOCUSED` | `false` | Set to `1` to open automation windows in the foreground (useful for debugging). The `--focus` flag sets this. |
|
|
161
|
+
| `OPENCLI_LIVE` | `false` | Set to `1` to keep the automation window open after an adapter command finishes (useful for inspection). The `--live` flag sets this. |
|
|
161
162
|
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | Seconds to wait for browser connection |
|
|
162
163
|
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a single browser command |
|
|
163
164
|
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol endpoint for remote browser or Electron apps |
|
|
@@ -166,6 +167,8 @@ OpenCLI is not only for websites. It can also:
|
|
|
166
167
|
| `OPENCLI_DIAGNOSTIC` | `false` | Set to `1` to capture structured diagnostic context on failures |
|
|
167
168
|
| `DEBUG_SNAPSHOT` | — | Set to `1` for DOM snapshot debug output |
|
|
168
169
|
|
|
170
|
+
`--focus` works for both `opencli browser *` and browser-backed adapter commands. `--live` is mainly for adapter commands: browser subcommands already keep the automation window open until you run `opencli browser close` or the idle timeout expires.
|
|
171
|
+
|
|
169
172
|
## Update
|
|
170
173
|
|
|
171
174
|
```bash
|
|
@@ -205,7 +208,7 @@ To load the source Browser Bridge extension:
|
|
|
205
208
|
| Site | Commands |
|
|
206
209
|
|------|----------|
|
|
207
210
|
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
|
|
208
|
-
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
|
|
211
|
+
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `video` `user-videos` |
|
|
209
212
|
| **tieba** | `hot` `posts` `search` `read` |
|
|
210
213
|
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
|
|
211
214
|
| **twitter** | `trending` `search` `timeline` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
|
package/README.zh-CN.md
CHANGED
|
@@ -155,7 +155,8 @@ OpenCLI 不只是网站 CLI,还可以:
|
|
|
155
155
|
| 变量 | 默认值 | 说明 |
|
|
156
156
|
|------|--------|------|
|
|
157
157
|
| `OPENCLI_DAEMON_PORT` | `19825` | daemon-extension 通信端口 |
|
|
158
|
-
| `OPENCLI_WINDOW_FOCUSED` | `false` | 设为 `1` 时 automation
|
|
158
|
+
| `OPENCLI_WINDOW_FOCUSED` | `false` | 设为 `1` 时 automation 窗口在前台打开(适合调试)。`--focus` 标志会设置此变量 |
|
|
159
|
+
| `OPENCLI_LIVE` | `false` | 设为 `1` 时 adapter 命令执行完后保留 automation 窗口不关闭(适合检查页面)。`--live` 标志会设置此变量 |
|
|
159
160
|
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `30` | 浏览器连接超时(秒) |
|
|
160
161
|
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | 单个浏览器命令超时(秒) |
|
|
161
162
|
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 |
|
|
@@ -164,6 +165,8 @@ OpenCLI 不只是网站 CLI,还可以:
|
|
|
164
165
|
| `OPENCLI_DIAGNOSTIC` | `false` | 设为 `1` 时在失败时输出结构化诊断上下文 |
|
|
165
166
|
| `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 |
|
|
166
167
|
|
|
168
|
+
`--focus` 同时适用于 `opencli browser *` 和浏览器型 adapter 命令。`--live` 主要是给 adapter 命令用的:`browser` 子命令本来就会一直保留 automation window,直到你手动执行 `opencli browser close` 或等空闲超时。
|
|
169
|
+
|
|
167
170
|
## 更新
|
|
168
171
|
|
|
169
172
|
```bash
|
|
@@ -209,7 +212,7 @@ npm link
|
|
|
209
212
|
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
|
|
210
213
|
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 |
|
|
211
214
|
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
|
|
212
|
-
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
|
215
|
+
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `video` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
|
213
216
|
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 |
|
|
214
217
|
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
|
|
215
218
|
| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 |
|
package/cli-manifest.json
CHANGED
|
@@ -1716,6 +1716,30 @@
|
|
|
1716
1716
|
"sourceFile": "bilibili/user-videos.js",
|
|
1717
1717
|
"navigateBefore": "https://www.bilibili.com"
|
|
1718
1718
|
},
|
|
1719
|
+
{
|
|
1720
|
+
"site": "bilibili",
|
|
1721
|
+
"name": "video",
|
|
1722
|
+
"description": "Get Bilibili video metadata (title, author, duration, stats, etc.)",
|
|
1723
|
+
"strategy": "cookie",
|
|
1724
|
+
"browser": true,
|
|
1725
|
+
"args": [
|
|
1726
|
+
{
|
|
1727
|
+
"name": "bvid",
|
|
1728
|
+
"type": "str",
|
|
1729
|
+
"required": true,
|
|
1730
|
+
"positional": true,
|
|
1731
|
+
"help": "BV ID, video URL, or b23.tv short link"
|
|
1732
|
+
}
|
|
1733
|
+
],
|
|
1734
|
+
"columns": [
|
|
1735
|
+
"field",
|
|
1736
|
+
"value"
|
|
1737
|
+
],
|
|
1738
|
+
"type": "js",
|
|
1739
|
+
"modulePath": "bilibili/video.js",
|
|
1740
|
+
"sourceFile": "bilibili/video.js",
|
|
1741
|
+
"navigateBefore": true
|
|
1742
|
+
},
|
|
1719
1743
|
{
|
|
1720
1744
|
"site": "binance",
|
|
1721
1745
|
"name": "asks",
|
|
@@ -4118,6 +4142,12 @@
|
|
|
4118
4142
|
"default": false,
|
|
4119
4143
|
"required": false,
|
|
4120
4144
|
"help": "Enable web search"
|
|
4145
|
+
},
|
|
4146
|
+
{
|
|
4147
|
+
"name": "file",
|
|
4148
|
+
"type": "str",
|
|
4149
|
+
"required": false,
|
|
4150
|
+
"help": "Attach a file (PDF, image, text) with the prompt"
|
|
4121
4151
|
}
|
|
4122
4152
|
],
|
|
4123
4153
|
"columns": [
|
|
@@ -8908,13 +8938,20 @@
|
|
|
8908
8938
|
"default": 20,
|
|
8909
8939
|
"required": false,
|
|
8910
8940
|
"help": "Number of results (max 50)"
|
|
8941
|
+
},
|
|
8942
|
+
{
|
|
8943
|
+
"name": "since_days",
|
|
8944
|
+
"type": "int",
|
|
8945
|
+
"required": false,
|
|
8946
|
+
"help": "Only keep rows published within N days"
|
|
8911
8947
|
}
|
|
8912
8948
|
],
|
|
8913
8949
|
"columns": [
|
|
8914
8950
|
"rank",
|
|
8915
8951
|
"content_type",
|
|
8916
8952
|
"title",
|
|
8917
|
-
"
|
|
8953
|
+
"published_at",
|
|
8954
|
+
"detail_status",
|
|
8918
8955
|
"project_code",
|
|
8919
8956
|
"budget_or_limit",
|
|
8920
8957
|
"url"
|
|
@@ -16163,6 +16200,45 @@
|
|
|
16163
16200
|
"sourceFile": "twitter/trending.js",
|
|
16164
16201
|
"navigateBefore": "https://x.com"
|
|
16165
16202
|
},
|
|
16203
|
+
{
|
|
16204
|
+
"site": "twitter",
|
|
16205
|
+
"name": "tweets",
|
|
16206
|
+
"description": "Fetch a Twitter user's most recent tweets (chronological, excludes pinned)",
|
|
16207
|
+
"domain": "x.com",
|
|
16208
|
+
"strategy": "cookie",
|
|
16209
|
+
"browser": true,
|
|
16210
|
+
"args": [
|
|
16211
|
+
{
|
|
16212
|
+
"name": "username",
|
|
16213
|
+
"type": "string",
|
|
16214
|
+
"required": true,
|
|
16215
|
+
"positional": true,
|
|
16216
|
+
"help": "Twitter screen name (with or without @)"
|
|
16217
|
+
},
|
|
16218
|
+
{
|
|
16219
|
+
"name": "limit",
|
|
16220
|
+
"type": "int",
|
|
16221
|
+
"default": 20,
|
|
16222
|
+
"required": false,
|
|
16223
|
+
"help": "Max tweets to return"
|
|
16224
|
+
}
|
|
16225
|
+
],
|
|
16226
|
+
"columns": [
|
|
16227
|
+
"author",
|
|
16228
|
+
"created_at",
|
|
16229
|
+
"is_retweet",
|
|
16230
|
+
"text",
|
|
16231
|
+
"likes",
|
|
16232
|
+
"retweets",
|
|
16233
|
+
"replies",
|
|
16234
|
+
"views",
|
|
16235
|
+
"url"
|
|
16236
|
+
],
|
|
16237
|
+
"type": "js",
|
|
16238
|
+
"modulePath": "twitter/tweets.js",
|
|
16239
|
+
"sourceFile": "twitter/tweets.js",
|
|
16240
|
+
"navigateBefore": "https://x.com"
|
|
16241
|
+
},
|
|
16166
16242
|
{
|
|
16167
16243
|
"site": "twitter",
|
|
16168
16244
|
"name": "unblock",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { apiGet, resolveBvid } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'bilibili',
|
|
7
|
+
name: 'video',
|
|
8
|
+
description: 'Get Bilibili video metadata (title, author, duration, stats, etc.)',
|
|
9
|
+
strategy: Strategy.COOKIE,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'bvid', required: true, positional: true, help: 'BV ID, video URL, or b23.tv short link' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['field', 'value'],
|
|
14
|
+
func: async (page, kwargs) => {
|
|
15
|
+
if (!page) {
|
|
16
|
+
throw new CommandExecutionError('Browser session required for bilibili video');
|
|
17
|
+
}
|
|
18
|
+
const bvid = await resolveBvid(kwargs.bvid);
|
|
19
|
+
|
|
20
|
+
// Navigate to video page first so subsequent api call shares a primed session.
|
|
21
|
+
await page.goto(`https://www.bilibili.com/video/${bvid}/`);
|
|
22
|
+
|
|
23
|
+
const payload = await apiGet(page, '/x/web-interface/view', {
|
|
24
|
+
params: { bvid },
|
|
25
|
+
});
|
|
26
|
+
if (payload.code !== 0) {
|
|
27
|
+
throw new CommandExecutionError(`Bilibili view API failed: ${payload.message} (${payload.code})`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const d = payload.data || {};
|
|
31
|
+
const stat = d.stat || {};
|
|
32
|
+
const owner = d.owner || {};
|
|
33
|
+
|
|
34
|
+
const pubDate = d.pubdate ? new Date(d.pubdate * 1000).toISOString().slice(0, 16).replace('T', ' ') : '';
|
|
35
|
+
const dur = d.duration || 0;
|
|
36
|
+
const mm = Math.floor(dur / 60);
|
|
37
|
+
const ss = dur % 60;
|
|
38
|
+
const desc = (d.desc || '').replace(/\s+/g, ' ').trim();
|
|
39
|
+
const descTrunc = desc.length > 200 ? desc.slice(0, 200) + '…' : desc;
|
|
40
|
+
|
|
41
|
+
return [
|
|
42
|
+
{ field: 'bvid', value: d.bvid ?? '' },
|
|
43
|
+
{ field: 'aid', value: String(d.aid ?? '') },
|
|
44
|
+
{ field: 'title', value: d.title ?? '' },
|
|
45
|
+
{ field: 'author', value: owner.name ? `${owner.name} (mid: ${owner.mid})` : '' },
|
|
46
|
+
{ field: 'category', value: d.tname_v2 || d.tname || '' },
|
|
47
|
+
{ field: 'publish_time', value: pubDate },
|
|
48
|
+
{ field: 'duration', value: dur ? `${mm}m${ss}s (${dur}s)` : '' },
|
|
49
|
+
{ field: 'view', value: String(stat.view ?? '') },
|
|
50
|
+
{ field: 'danmaku', value: String(stat.danmaku ?? '') },
|
|
51
|
+
{ field: 'reply', value: String(stat.reply ?? '') },
|
|
52
|
+
{ field: 'like', value: String(stat.like ?? '') },
|
|
53
|
+
{ field: 'coin', value: String(stat.coin ?? '') },
|
|
54
|
+
{ field: 'favorite', value: String(stat.favorite ?? '') },
|
|
55
|
+
{ field: 'share', value: String(stat.share ?? '') },
|
|
56
|
+
{ field: 'parts', value: String(d.videos ?? 1) },
|
|
57
|
+
{ field: 'thumbnail', value: d.pic ?? '' },
|
|
58
|
+
{ field: 'description', value: descTrunc },
|
|
59
|
+
];
|
|
60
|
+
},
|
|
61
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
const { mockApiGet } = vi.hoisted(() => ({
|
|
5
|
+
mockApiGet: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('./utils.js', async (importOriginal) => ({
|
|
9
|
+
...(await importOriginal()),
|
|
10
|
+
apiGet: mockApiGet,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
14
|
+
import './video.js';
|
|
15
|
+
|
|
16
|
+
describe('bilibili video', () => {
|
|
17
|
+
const command = getRegistry().get('bilibili/video');
|
|
18
|
+
const page = {
|
|
19
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
evaluate: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
mockApiGet.mockReset();
|
|
25
|
+
page.goto.mockClear();
|
|
26
|
+
page.evaluate.mockReset();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns a field/value table of video metadata on success', async () => {
|
|
30
|
+
mockApiGet.mockResolvedValueOnce({
|
|
31
|
+
code: 0,
|
|
32
|
+
data: {
|
|
33
|
+
bvid: 'BV1xx411c7mD',
|
|
34
|
+
aid: 12345678,
|
|
35
|
+
title: '三层结构笔记法',
|
|
36
|
+
tname: '教程',
|
|
37
|
+
pubdate: 1775053078, // 2026-04-01 14:17:58 UTC
|
|
38
|
+
duration: 434,
|
|
39
|
+
videos: 1,
|
|
40
|
+
pic: 'https://i1.hdslb.com/some.jpg',
|
|
41
|
+
desc: 'Obsidian 教程',
|
|
42
|
+
owner: { mid: 507578555, name: 'IOI科技' },
|
|
43
|
+
stat: { view: 6128, danmaku: 0, reply: 21, like: 162, coin: 48, favorite: 564, share: 26 },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
|
|
48
|
+
|
|
49
|
+
// Every row has { field, value }
|
|
50
|
+
expect(Array.isArray(rows)).toBe(true);
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
expect(row).toHaveProperty('field');
|
|
53
|
+
expect(row).toHaveProperty('value');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
|
57
|
+
expect(byField.bvid).toBe('BV1xx411c7mD');
|
|
58
|
+
expect(byField.title).toBe('三层结构笔记法');
|
|
59
|
+
expect(byField.author).toBe('IOI科技 (mid: 507578555)');
|
|
60
|
+
expect(byField.duration).toBe('7m14s (434s)');
|
|
61
|
+
expect(byField.view).toBe('6128');
|
|
62
|
+
expect(byField.like).toBe('162');
|
|
63
|
+
|
|
64
|
+
// Navigation primes the session
|
|
65
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD/');
|
|
66
|
+
// API called without signing
|
|
67
|
+
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('throws CommandExecutionError when bilibili view API returns non-zero code', async () => {
|
|
71
|
+
mockApiGet.mockResolvedValueOnce({
|
|
72
|
+
code: -404,
|
|
73
|
+
message: '啥都木有',
|
|
74
|
+
data: null,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await expect(command.func(page, { bvid: 'BV1xx411c7mD' })).rejects.toSatisfy(
|
|
78
|
+
(err) => err instanceof CommandExecutionError && /啥都木有|-404/.test(err.message),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
package/clis/deepseek/ask.js
CHANGED
|
@@ -2,7 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
|
2
2
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
3
|
import {
|
|
4
4
|
DEEPSEEK_DOMAIN, DEEPSEEK_URL, ensureOnDeepSeek, selectModel, setFeature,
|
|
5
|
-
sendMessage, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
|
|
5
|
+
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
|
|
6
6
|
} from './utils.js';
|
|
7
7
|
|
|
8
8
|
export const askCommand = cli({
|
|
@@ -21,6 +21,7 @@ export const askCommand = cli({
|
|
|
21
21
|
{ name: 'model', default: 'instant', choices: ['instant', 'expert'], help: 'Model to use: instant or expert' },
|
|
22
22
|
{ name: 'think', type: 'boolean', default: false, help: 'Enable DeepThink mode' },
|
|
23
23
|
{ name: 'search', type: 'boolean', default: false, help: 'Enable web search' },
|
|
24
|
+
{ name: 'file', help: 'Attach a file (PDF, image, text) with the prompt' },
|
|
24
25
|
],
|
|
25
26
|
columns: ['response'],
|
|
26
27
|
|
|
@@ -58,6 +59,25 @@ export const askCommand = cli({
|
|
|
58
59
|
|
|
59
60
|
if (thinkResult.toggled || searchResult.toggled) await page.wait(0.5);
|
|
60
61
|
|
|
62
|
+
if (kwargs.file) {
|
|
63
|
+
const baseline = await withRetry(() => getBubbleCount(page));
|
|
64
|
+
try {
|
|
65
|
+
const fileResult = await sendWithFile(page, kwargs.file, prompt);
|
|
66
|
+
if (fileResult && !fileResult.ok) {
|
|
67
|
+
throw new CommandExecutionError(fileResult.reason || 'Failed to attach file');
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// SPA navigates after send; "Promise was collected" means send succeeded
|
|
71
|
+
if (!String(err?.message || err).includes('Promise was collected')) throw err;
|
|
72
|
+
}
|
|
73
|
+
await page.wait(3);
|
|
74
|
+
const response = await waitForResponse(page, baseline, prompt, timeoutMs);
|
|
75
|
+
if (!response) {
|
|
76
|
+
return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
|
|
77
|
+
}
|
|
78
|
+
return [{ response }];
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
const baseline = await withRetry(() => getBubbleCount(page));
|
|
62
82
|
const sendResult = await withRetry(() => sendMessage(page, prompt));
|
|
63
83
|
if (!sendResult?.ok) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
mockEnsureOnDeepSeek,
|
|
5
|
+
mockSelectModel,
|
|
6
|
+
mockSetFeature,
|
|
7
|
+
mockSendMessage,
|
|
8
|
+
mockSendWithFile,
|
|
9
|
+
mockGetBubbleCount,
|
|
10
|
+
mockWaitForResponse,
|
|
11
|
+
mockParseBoolFlag,
|
|
12
|
+
mockWithRetry,
|
|
13
|
+
} = vi.hoisted(() => ({
|
|
14
|
+
mockEnsureOnDeepSeek: vi.fn(),
|
|
15
|
+
mockSelectModel: vi.fn(),
|
|
16
|
+
mockSetFeature: vi.fn(),
|
|
17
|
+
mockSendMessage: vi.fn(),
|
|
18
|
+
mockSendWithFile: vi.fn(),
|
|
19
|
+
mockGetBubbleCount: vi.fn(),
|
|
20
|
+
mockWaitForResponse: vi.fn(),
|
|
21
|
+
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
|
|
22
|
+
mockWithRetry: vi.fn(async (fn) => fn()),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('./utils.js', () => ({
|
|
26
|
+
DEEPSEEK_DOMAIN: 'chat.deepseek.com',
|
|
27
|
+
DEEPSEEK_URL: 'https://chat.deepseek.com/',
|
|
28
|
+
ensureOnDeepSeek: mockEnsureOnDeepSeek,
|
|
29
|
+
selectModel: mockSelectModel,
|
|
30
|
+
setFeature: mockSetFeature,
|
|
31
|
+
sendMessage: mockSendMessage,
|
|
32
|
+
sendWithFile: mockSendWithFile,
|
|
33
|
+
getBubbleCount: mockGetBubbleCount,
|
|
34
|
+
waitForResponse: mockWaitForResponse,
|
|
35
|
+
parseBoolFlag: mockParseBoolFlag,
|
|
36
|
+
withRetry: mockWithRetry,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
import { askCommand } from './ask.js';
|
|
40
|
+
|
|
41
|
+
describe('deepseek ask --file', () => {
|
|
42
|
+
const page = {
|
|
43
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
44
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
mockEnsureOnDeepSeek.mockResolvedValue(undefined);
|
|
50
|
+
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
|
51
|
+
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
|
52
|
+
mockSendWithFile.mockResolvedValue({ ok: true });
|
|
53
|
+
mockGetBubbleCount.mockResolvedValue(7);
|
|
54
|
+
mockWaitForResponse.mockResolvedValue('new reply');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('captures the existing baseline before sending a file prompt', async () => {
|
|
58
|
+
const rows = await askCommand.func(page, {
|
|
59
|
+
prompt: 'summarize this',
|
|
60
|
+
timeout: 120,
|
|
61
|
+
file: './report.pdf',
|
|
62
|
+
new: false,
|
|
63
|
+
model: 'instant',
|
|
64
|
+
think: false,
|
|
65
|
+
search: false,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(rows).toEqual([{ response: 'new reply' }]);
|
|
69
|
+
expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(mockSendWithFile).toHaveBeenCalledWith(page, './report.pdf', 'summarize this');
|
|
71
|
+
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 7, 'summarize this', 120000);
|
|
72
|
+
});
|
|
73
|
+
});
|
package/clis/deepseek/utils.js
CHANGED
|
@@ -161,7 +161,6 @@ export async function getConversationList(page) {
|
|
|
161
161
|
if (btn) btn.click();
|
|
162
162
|
}
|
|
163
163
|
})()`);
|
|
164
|
-
// Poll for sidebar history links to render
|
|
165
164
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
166
165
|
await page.wait(2);
|
|
167
166
|
const items = await page.evaluate(`(() => {
|
|
@@ -186,6 +185,90 @@ export async function getConversationList(page) {
|
|
|
186
185
|
return [];
|
|
187
186
|
}
|
|
188
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
|
+
|
|
189
272
|
// Retries on CDP "Promise was collected" errors caused by DeepSeek's SPA router transitions.
|
|
190
273
|
export async function withRetry(fn, retries = 2) {
|
|
191
274
|
for (let i = 0; i <= retries; i++) {
|
|
@@ -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
|
+
});
|