@jackwener/opencli 1.7.8 → 1.7.10
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 +49 -14
- package/README.zh-CN.md +30 -10
- package/cli-manifest.json +646 -30
- package/clis/36kr/news.js +1 -1
- package/clis/apple-podcasts/commands.test.js +4 -4
- package/clis/apple-podcasts/episodes.js +1 -1
- package/clis/apple-podcasts/search.js +1 -1
- package/clis/apple-podcasts/top.js +1 -1
- package/clis/arxiv/paper.js +1 -1
- package/clis/arxiv/search.js +1 -1
- package/clis/band/mentions.js +3 -3
- package/clis/bbc/news.js +1 -1
- package/clis/bilibili/subtitle.js +2 -2
- package/clis/bloomberg/businessweek.js +1 -1
- package/clis/bloomberg/economics.js +1 -1
- package/clis/bloomberg/industries.js +1 -1
- package/clis/bloomberg/main.js +1 -1
- package/clis/bloomberg/markets.js +1 -1
- package/clis/bloomberg/opinions.js +1 -1
- package/clis/bloomberg/politics.js +1 -1
- package/clis/bloomberg/tech.js +1 -1
- package/clis/boss/search.js +49 -8
- package/clis/boss/search.test.js +78 -0
- package/clis/boss/send.js +3 -3
- package/clis/chatgpt/image.js +37 -8
- package/clis/chatgpt/image.test.js +92 -0
- package/clis/chatgpt/utils.js +39 -6
- package/clis/chatgpt/utils.test.js +63 -0
- package/clis/chatgpt-app/ask.js +1 -1
- package/clis/chatgpt-app/ax.js +4 -2
- package/clis/chatgpt-app/ax.test.js +12 -0
- package/clis/chatgpt-app/model.js +1 -1
- package/clis/chatgpt-app/new.js +1 -1
- package/clis/chatgpt-app/read.js +1 -1
- package/clis/chatgpt-app/send.js +1 -1
- package/clis/chatgpt-app/status.js +1 -1
- package/clis/chatwise/ask.js +2 -2
- package/clis/chatwise/model.js +2 -2
- package/clis/chatwise/send.js +2 -2
- package/clis/claude/ask.js +128 -0
- package/clis/claude/ask.test.js +338 -0
- package/clis/claude/commands.test.js +118 -0
- package/clis/claude/detail.js +29 -0
- package/clis/claude/history.js +31 -0
- package/clis/claude/new.js +21 -0
- package/clis/claude/read.js +24 -0
- package/clis/claude/send.js +41 -0
- package/clis/claude/status.js +24 -0
- package/clis/claude/utils.js +440 -0
- package/clis/claude/utils.test.js +148 -0
- package/clis/codex/ask.js +2 -2
- package/clis/codex/send.js +2 -2
- package/clis/ctrip/search.js +1 -1
- package/clis/ctrip/search.test.js +4 -4
- package/clis/cursor/ask.js +2 -2
- package/clis/cursor/composer.js +2 -2
- package/clis/cursor/send.js +2 -2
- package/clis/deepseek/ask.js +17 -4
- package/clis/deepseek/ask.test.js +46 -0
- package/clis/deepseek/utils.js +55 -16
- package/clis/deepseek/utils.test.js +124 -5
- package/clis/doubao/utils.js +53 -11
- package/clis/doubao/utils.test.js +22 -2
- package/clis/eastmoney/announcement.js +1 -1
- package/clis/eastmoney/convertible.js +1 -1
- package/clis/eastmoney/etf.js +1 -1
- package/clis/eastmoney/holders.js +1 -1
- package/clis/eastmoney/index-board.js +1 -1
- package/clis/eastmoney/kline.js +1 -1
- package/clis/eastmoney/kuaixun.js +1 -1
- package/clis/eastmoney/longhu.js +1 -1
- package/clis/eastmoney/money-flow.js +1 -1
- package/clis/eastmoney/northbound.js +1 -1
- package/clis/eastmoney/quote.js +1 -1
- package/clis/eastmoney/rank.js +1 -1
- package/clis/eastmoney/sectors.js +1 -1
- package/clis/facebook/marketplace-inbox.js +83 -0
- package/clis/facebook/marketplace-listings.js +83 -0
- package/clis/facebook/marketplace.test.js +91 -0
- package/clis/google/news.js +1 -1
- package/clis/google/suggest.js +1 -1
- package/clis/google/trends.js +1 -1
- package/clis/google-scholar/cite.js +74 -0
- package/clis/google-scholar/cite.test.js +47 -0
- package/clis/google-scholar/profile.js +92 -0
- package/clis/google-scholar/profile.test.js +49 -0
- package/clis/google-scholar/search.js +1 -1
- package/clis/google-scholar/search.test.js +15 -0
- package/clis/hf/top.js +1 -1
- package/clis/instagram/collection-create.js +57 -0
- package/clis/instagram/saved.js +21 -7
- package/clis/jd/item.js +679 -47
- package/clis/jd/item.test.js +318 -7
- package/clis/jd/item.test.ts +517 -0
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/tags.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/lesswrong/user-posts.js +1 -1
- package/clis/lesswrong/user.js +1 -1
- package/clis/paperreview/commands.test.js +6 -6
- package/clis/paperreview/feedback.js +1 -1
- package/clis/paperreview/review.js +1 -1
- package/clis/paperreview/submit.js +1 -1
- package/clis/producthunt/posts.js +1 -1
- package/clis/producthunt/today.js +1 -1
- package/clis/sinablog/search.js +1 -1
- package/clis/sinafinance/news.js +1 -1
- package/clis/sinafinance/stock.js +1 -1
- package/clis/sinafinance/stock.test.js +2 -2
- package/clis/spotify/spotify.js +6 -6
- package/clis/substack/search.js +1 -1
- package/clis/toutiao/articles.js +5 -6
- package/clis/toutiao/articles.test.js +22 -15
- package/clis/twitter/followers.js +2 -2
- package/clis/twitter/following.js +224 -73
- package/clis/twitter/following.test.js +277 -0
- package/clis/twitter/post.js +184 -47
- package/clis/twitter/post.test.js +114 -34
- package/clis/uiverse/_shared.js +63 -4
- package/clis/uiverse/_shared.test.js +7 -0
- package/clis/uiverse/code.js +1 -0
- package/clis/uiverse/navigation.test.js +12 -0
- package/clis/uiverse/preview.js +1 -0
- package/clis/web/read.js +319 -81
- package/clis/web/read.test.js +221 -5
- package/clis/weibo/favorites.js +169 -0
- package/clis/weibo/favorites.test.js +114 -0
- package/clis/weibo/publish.js +282 -0
- package/clis/weibo/publish.test.js +183 -0
- package/clis/weread/ranking.js +1 -1
- package/clis/weread/search-regression.test.js +8 -8
- package/clis/weread/search.js +1 -1
- package/clis/wikipedia/random.js +1 -1
- package/clis/wikipedia/search.js +1 -1
- package/clis/wikipedia/summary.js +1 -1
- package/clis/wikipedia/trending.js +1 -1
- package/clis/xianyu/chat.js +3 -3
- package/clis/xianyu/item.js +2 -2
- package/clis/xianyu/item.test.js +3 -3
- package/clis/xiaohongshu/search.js +17 -2
- package/clis/xiaohongshu/search.test.js +37 -1
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/download.test.js +3 -3
- package/clis/xiaoyuzhou/episode.js +1 -1
- package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
- package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
- package/clis/xiaoyuzhou/podcast.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/xiaoyuzhou/transcript.test.js +5 -5
- package/clis/yollomi/models.js +1 -1
- package/clis/youtube/channel.js +24 -1
- package/clis/youtube/channel.test.js +59 -0
- package/clis/zhihu/answer.js +21 -162
- package/clis/zhihu/answer.test.js +26 -53
- package/clis/zhihu/collection.js +197 -0
- package/clis/zhihu/collection.test.js +290 -0
- package/clis/zhihu/collections.js +127 -0
- package/clis/zhihu/collections.test.js +182 -0
- package/clis/zhihu/comment.js +24 -305
- package/clis/zhihu/comment.test.js +31 -35
- package/clis/zhihu/favorite.js +44 -182
- package/clis/zhihu/favorite.test.js +30 -167
- package/clis/zhihu/follow.js +25 -56
- package/clis/zhihu/follow.test.js +20 -23
- package/clis/zhihu/like.js +22 -67
- package/clis/zhihu/like.test.js +19 -42
- package/clis/zhihu/search.js +3 -2
- package/clis/zhihu/write-shared.js +8 -1
- package/clis/zhihu/write-shared.test.js +1 -0
- package/clis/zlibrary/commands.test.js +75 -0
- package/clis/zlibrary/info.js +47 -0
- package/clis/zlibrary/search.js +46 -0
- package/clis/zlibrary/utils.js +136 -0
- package/dist/src/adapter-source.d.ts +11 -0
- package/dist/src/adapter-source.js +24 -0
- package/dist/src/adapter-source.test.js +29 -0
- package/dist/src/browser/base-page.d.ts +3 -1
- package/dist/src/browser/base-page.js +76 -1
- package/dist/src/browser/base-page.test.d.ts +1 -0
- package/dist/src/browser/base-page.test.js +74 -0
- package/dist/src/browser/bridge.d.ts +1 -2
- package/dist/src/browser/bridge.js +40 -41
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +3 -3
- package/dist/src/browser/daemon-client.d.ts +38 -4
- package/dist/src/browser/daemon-client.js +24 -7
- package/dist/src/browser/daemon-client.test.js +49 -0
- package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
- package/dist/src/browser/daemon-lifecycle.js +67 -0
- package/dist/src/browser/daemon-version.d.ts +4 -0
- package/dist/src/browser/daemon-version.js +12 -0
- package/dist/src/browser/errors.js +3 -0
- package/dist/src/browser/errors.test.js +3 -0
- package/dist/src/browser/network-cache.d.ts +1 -0
- package/dist/src/browser/page.d.ts +3 -1
- package/dist/src/browser/page.js +10 -2
- package/dist/src/browser/profile.d.ts +14 -0
- package/dist/src/browser/profile.js +85 -0
- package/dist/src/build-manifest.d.ts +2 -0
- package/dist/src/build-manifest.js +13 -3
- package/dist/src/build-manifest.test.js +20 -2
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.js +477 -35
- package/dist/src/cli.test.js +303 -2
- package/dist/src/commanderAdapter.js +17 -9
- package/dist/src/commanderAdapter.test.js +67 -2
- package/dist/src/commands/daemon.d.ts +2 -0
- package/dist/src/commands/daemon.js +42 -1
- package/dist/src/commands/daemon.test.js +103 -2
- package/dist/src/completion-shared.js +1 -2
- package/dist/src/completion.test.js +3 -2
- package/dist/src/daemon.js +125 -41
- package/dist/src/doctor.d.ts +5 -6
- package/dist/src/doctor.js +77 -19
- package/dist/src/doctor.test.js +117 -0
- package/dist/src/engine.test.js +6 -5
- package/dist/src/errors.d.ts +14 -8
- package/dist/src/errors.js +36 -30
- package/dist/src/errors.test.js +5 -5
- package/dist/src/execution.d.ts +4 -0
- package/dist/src/execution.js +173 -25
- package/dist/src/execution.test.js +171 -1
- package/dist/src/main.js +10 -0
- package/dist/src/observation/artifact.d.ts +16 -0
- package/dist/src/observation/artifact.js +260 -0
- package/dist/src/observation/artifact.test.d.ts +1 -0
- package/dist/src/observation/artifact.test.js +121 -0
- package/dist/src/observation/events.d.ts +89 -0
- package/dist/src/observation/events.js +1 -0
- package/dist/src/observation/index.d.ts +7 -0
- package/dist/src/observation/index.js +7 -0
- package/dist/src/observation/manager.d.ts +9 -0
- package/dist/src/observation/manager.js +27 -0
- package/dist/src/observation/manager.test.d.ts +1 -0
- package/dist/src/observation/manager.test.js +13 -0
- package/dist/src/observation/redaction.d.ts +11 -0
- package/dist/src/observation/redaction.js +81 -0
- package/dist/src/observation/redaction.test.d.ts +1 -0
- package/dist/src/observation/redaction.test.js +32 -0
- package/dist/src/observation/retention.d.ts +32 -0
- package/dist/src/observation/retention.js +160 -0
- package/dist/src/observation/retention.test.d.ts +1 -0
- package/dist/src/observation/retention.test.js +118 -0
- package/dist/src/observation/ring-buffer.d.ts +22 -0
- package/dist/src/observation/ring-buffer.js +45 -0
- package/dist/src/observation/ring-buffer.test.d.ts +1 -0
- package/dist/src/observation/ring-buffer.test.js +22 -0
- package/dist/src/observation/session.d.ts +25 -0
- package/dist/src/observation/session.js +50 -0
- package/dist/src/pipeline/executor.test.js +1 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/pipeline/steps/fetch.js +1 -21
- package/dist/src/pipeline/steps/fetch.test.js +6 -12
- package/dist/src/plugin-scaffold.js +1 -1
- package/dist/src/plugin-scaffold.test.js +1 -1
- package/dist/src/registry.d.ts +40 -9
- package/dist/src/registry.js +3 -1
- package/dist/src/runtime-detect.d.ts +10 -0
- package/dist/src/runtime-detect.js +19 -0
- package/dist/src/runtime-detect.test.js +12 -1
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +1 -0
- package/dist/src/types.d.ts +22 -0
- package/dist/src/update-check.d.ts +31 -1
- package/dist/src/update-check.js +62 -16
- package/dist/src/update-check.test.js +86 -1
- package/package.json +1 -1
- package/dist/src/diagnostic.d.ts +0 -63
- package/dist/src/diagnostic.js +0 -292
- package/dist/src/diagnostic.test.js +0 -302
- /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
package/clis/36kr/news.js
CHANGED
|
@@ -12,7 +12,7 @@ cli({
|
|
|
12
12
|
{ name: 'limit', type: 'int', default: 20, help: 'Number of articles (max 50)' },
|
|
13
13
|
],
|
|
14
14
|
columns: ['rank', 'title', 'summary', 'date', 'url'],
|
|
15
|
-
func: async (
|
|
15
|
+
func: async (kwargs) => {
|
|
16
16
|
const count = Math.min(kwargs.limit || 20, 50);
|
|
17
17
|
const resp = await fetch('https://www.36kr.com/feed', {
|
|
18
18
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli/1.0)' },
|
|
@@ -24,7 +24,7 @@ describe('apple-podcasts search command', () => {
|
|
|
24
24
|
}),
|
|
25
25
|
});
|
|
26
26
|
vi.stubGlobal('fetch', fetchMock);
|
|
27
|
-
const result = await cmd.func(
|
|
27
|
+
const result = await cmd.func({
|
|
28
28
|
query: 'machine learning',
|
|
29
29
|
keyword: 'sports',
|
|
30
30
|
limit: 5,
|
|
@@ -60,7 +60,7 @@ describe('apple-podcasts top command', () => {
|
|
|
60
60
|
}),
|
|
61
61
|
});
|
|
62
62
|
vi.stubGlobal('fetch', fetchMock);
|
|
63
|
-
await cmd.func(
|
|
63
|
+
await cmd.func({ country: 'US', limit: 1 });
|
|
64
64
|
const [, options] = fetchMock.mock.calls[0] ?? [];
|
|
65
65
|
expect(options).toBeDefined();
|
|
66
66
|
expect(options.signal).toBeDefined();
|
|
@@ -81,7 +81,7 @@ describe('apple-podcasts top command', () => {
|
|
|
81
81
|
}),
|
|
82
82
|
});
|
|
83
83
|
vi.stubGlobal('fetch', fetchMock);
|
|
84
|
-
const result = await cmd.func(
|
|
84
|
+
const result = await cmd.func({ country: 'US', limit: 2 });
|
|
85
85
|
expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json', expect.objectContaining({
|
|
86
86
|
signal: expect.any(Object),
|
|
87
87
|
}));
|
|
@@ -94,6 +94,6 @@ describe('apple-podcasts top command', () => {
|
|
|
94
94
|
const cmd = getRegistry().get('apple-podcasts/top');
|
|
95
95
|
expect(cmd?.func).toBeTypeOf('function');
|
|
96
96
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
|
|
97
|
-
await expect(cmd.func(
|
|
97
|
+
await expect(cmd.func({ country: 'us', limit: 3 })).rejects.toThrow('Unable to reach Apple Podcasts charts for US');
|
|
98
98
|
});
|
|
99
99
|
});
|
|
@@ -12,7 +12,7 @@ cli({
|
|
|
12
12
|
{ name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' },
|
|
13
13
|
],
|
|
14
14
|
columns: ['title', 'duration', 'date'],
|
|
15
|
-
func: async (
|
|
15
|
+
func: async (args) => {
|
|
16
16
|
const limit = Math.max(1, Math.min(Number(args.limit), 200));
|
|
17
17
|
// results[0] is the podcast itself; the rest are episodes
|
|
18
18
|
const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`);
|
|
@@ -12,7 +12,7 @@ cli({
|
|
|
12
12
|
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
|
13
13
|
],
|
|
14
14
|
columns: ['id', 'title', 'author', 'episodes', 'genre', 'url'],
|
|
15
|
-
func: async (
|
|
15
|
+
func: async (args) => {
|
|
16
16
|
const term = encodeURIComponent(args.query);
|
|
17
17
|
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
|
18
18
|
const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`);
|
|
@@ -14,7 +14,7 @@ cli({
|
|
|
14
14
|
{ name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' },
|
|
15
15
|
],
|
|
16
16
|
columns: ['rank', 'title', 'author', 'id'],
|
|
17
|
-
func: async (
|
|
17
|
+
func: async (args) => {
|
|
18
18
|
const limit = Math.max(1, Math.min(Number(args.limit), 100));
|
|
19
19
|
const country = String(args.country || 'us').trim().toLowerCase();
|
|
20
20
|
const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
|
package/clis/arxiv/paper.js
CHANGED
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
{ name: 'id', positional: true, required: true, help: 'arXiv paper ID (e.g. 1706.03762)' },
|
|
12
12
|
],
|
|
13
13
|
columns: ['id', 'title', 'authors', 'published', 'abstract', 'url'],
|
|
14
|
-
func: async (
|
|
14
|
+
func: async (args) => {
|
|
15
15
|
const xml = await arxivFetch(`id_list=${encodeURIComponent(args.id)}`);
|
|
16
16
|
const entries = parseEntries(xml);
|
|
17
17
|
if (!entries.length)
|
package/clis/arxiv/search.js
CHANGED
|
@@ -12,7 +12,7 @@ cli({
|
|
|
12
12
|
{ name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' },
|
|
13
13
|
],
|
|
14
14
|
columns: ['id', 'title', 'authors', 'published', 'url'],
|
|
15
|
-
func: async (
|
|
15
|
+
func: async (args) => {
|
|
16
16
|
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
|
17
17
|
const query = encodeURIComponent(`all:${args.query}`);
|
|
18
18
|
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
|
package/clis/band/mentions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AuthRequiredError, EmptyResultError,
|
|
1
|
+
import { AuthRequiredError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
3
|
/**
|
|
4
4
|
* band mentions — Show Band notifications where you were @mentioned.
|
|
@@ -52,7 +52,7 @@ cli({
|
|
|
52
52
|
await page.wait(0.5);
|
|
53
53
|
}
|
|
54
54
|
if (!bellReady) {
|
|
55
|
-
throw
|
|
55
|
+
throw selectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
|
|
56
56
|
}
|
|
57
57
|
// Poll until a capture containing result_data.news arrives, up to maxSecs seconds.
|
|
58
58
|
// getInterceptedRequests() clears the array on each call, so captures are accumulated
|
|
@@ -80,7 +80,7 @@ cli({
|
|
|
80
80
|
return true;
|
|
81
81
|
}`);
|
|
82
82
|
if (!bellClicked) {
|
|
83
|
-
throw
|
|
83
|
+
throw selectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
|
|
84
84
|
}
|
|
85
85
|
const requests = await waitForOneCapture();
|
|
86
86
|
// Find the get_news response (has result_data.news); get_news_count responses do not.
|
package/clis/bbc/news.js
CHANGED
|
@@ -12,7 +12,7 @@ cli({
|
|
|
12
12
|
{ name: 'limit', type: 'int', default: 20, help: 'Number of headlines (max 50)' },
|
|
13
13
|
],
|
|
14
14
|
columns: ['rank', 'title', 'description', 'url'],
|
|
15
|
-
func: async (
|
|
15
|
+
func: async (kwargs) => {
|
|
16
16
|
const count = Math.min(kwargs.limit || 20, 50);
|
|
17
17
|
const resp = await fetch('https://feeds.bbci.co.uk/news/rss.xml');
|
|
18
18
|
if (!resp.ok)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
import { AuthRequiredError, CommandExecutionError, EmptyResultError,
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
|
3
3
|
import { apiGet, resolveBvid } from './utils.js';
|
|
4
4
|
cli({
|
|
5
5
|
site: 'bilibili',
|
|
@@ -23,7 +23,7 @@ cli({
|
|
|
23
23
|
return state?.videoData?.cid;
|
|
24
24
|
})()`);
|
|
25
25
|
if (!cid) {
|
|
26
|
-
throw
|
|
26
|
+
throw selectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
|
27
27
|
}
|
|
28
28
|
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
|
|
29
29
|
// 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
|
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
|
12
12
|
],
|
|
13
13
|
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
|
14
|
-
func: async (
|
|
14
|
+
func: async (kwargs) => {
|
|
15
15
|
return fetchBloombergFeed('businessweek', kwargs.limit ?? 1);
|
|
16
16
|
},
|
|
17
17
|
});
|
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
|
12
12
|
],
|
|
13
13
|
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
|
14
|
-
func: async (
|
|
14
|
+
func: async (kwargs) => {
|
|
15
15
|
return fetchBloombergFeed('economics', kwargs.limit ?? 1);
|
|
16
16
|
},
|
|
17
17
|
});
|
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
|
12
12
|
],
|
|
13
13
|
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
|
14
|
-
func: async (
|
|
14
|
+
func: async (kwargs) => {
|
|
15
15
|
return fetchBloombergFeed('industries', kwargs.limit ?? 1);
|
|
16
16
|
},
|
|
17
17
|
});
|
package/clis/bloomberg/main.js
CHANGED
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
|
12
12
|
],
|
|
13
13
|
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
|
14
|
-
func: async (
|
|
14
|
+
func: async (kwargs) => {
|
|
15
15
|
return fetchBloombergFeed('main', kwargs.limit ?? 1);
|
|
16
16
|
},
|
|
17
17
|
});
|
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
|
12
12
|
],
|
|
13
13
|
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
|
14
|
-
func: async (
|
|
14
|
+
func: async (kwargs) => {
|
|
15
15
|
return fetchBloombergFeed('markets', kwargs.limit ?? 1);
|
|
16
16
|
},
|
|
17
17
|
});
|
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
|
12
12
|
],
|
|
13
13
|
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
|
14
|
-
func: async (
|
|
14
|
+
func: async (kwargs) => {
|
|
15
15
|
return fetchBloombergFeed('opinions', kwargs.limit ?? 1);
|
|
16
16
|
},
|
|
17
17
|
});
|
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
|
12
12
|
],
|
|
13
13
|
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
|
14
|
-
func: async (
|
|
14
|
+
func: async (kwargs) => {
|
|
15
15
|
return fetchBloombergFeed('politics', kwargs.limit ?? 1);
|
|
16
16
|
},
|
|
17
17
|
});
|
package/clis/bloomberg/tech.js
CHANGED
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
{ name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
|
|
12
12
|
],
|
|
13
13
|
columns: ['title', 'summary', 'link', 'mediaLinks'],
|
|
14
|
-
func: async (
|
|
14
|
+
func: async (kwargs) => {
|
|
15
15
|
return fetchBloombergFeed('tech', kwargs.limit ?? 1);
|
|
16
16
|
},
|
|
17
17
|
});
|
package/clis/boss/search.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* BOSS直聘 job search — browser cookie API.
|
|
3
3
|
*/
|
|
4
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
5
6
|
import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
|
|
6
7
|
/** City name → BOSS Zhipin city code mapping */
|
|
7
8
|
const CITY_CODES = {
|
|
@@ -22,8 +23,16 @@ const CITY_CODES = {
|
|
|
22
23
|
'香港': '101320100',
|
|
23
24
|
};
|
|
24
25
|
const EXP_MAP = {
|
|
25
|
-
'不限': '0',
|
|
26
|
-
'
|
|
26
|
+
'不限': '0',
|
|
27
|
+
'在校/应届': '108',
|
|
28
|
+
'在校生': '108', '在校': '108',
|
|
29
|
+
'应届生': '102', '应届': '102',
|
|
30
|
+
'经验不限': '101',
|
|
31
|
+
'1年以内': '103',
|
|
32
|
+
'1-3年': '104',
|
|
33
|
+
'3-5年': '105',
|
|
34
|
+
'5-10年': '106',
|
|
35
|
+
'10年以上': '107',
|
|
27
36
|
};
|
|
28
37
|
const DEGREE_MAP = {
|
|
29
38
|
'不限': '0', '初中及以下': '209', '中专/中技': '208', '高中': '206',
|
|
@@ -38,6 +47,10 @@ const INDUSTRY_MAP = {
|
|
|
38
47
|
'人工智能': '100901', '大数据': '100902', '金融': '100101',
|
|
39
48
|
'教育培训': '100200', '医疗健康': '100300',
|
|
40
49
|
};
|
|
50
|
+
const JOB_TYPE_MAP = {
|
|
51
|
+
'不限': '0', '全职': '1901', '实习': '1902', '兼职': '1903',
|
|
52
|
+
};
|
|
53
|
+
const JOB_TYPE_CODES = new Set(Object.values(JOB_TYPE_MAP));
|
|
41
54
|
function resolveCity(input) {
|
|
42
55
|
if (!input)
|
|
43
56
|
return '101010100';
|
|
@@ -62,35 +75,54 @@ function resolveMap(input, map) {
|
|
|
62
75
|
}
|
|
63
76
|
return input;
|
|
64
77
|
}
|
|
78
|
+
function resolveJobType(input) {
|
|
79
|
+
if (!input)
|
|
80
|
+
return '';
|
|
81
|
+
if (JOB_TYPE_MAP[input] !== undefined)
|
|
82
|
+
return JOB_TYPE_MAP[input];
|
|
83
|
+
if (JOB_TYPE_CODES.has(input))
|
|
84
|
+
return input;
|
|
85
|
+
throw new ArgumentError(`Invalid jobType: ${input}`, 'Use one of: 全职, 兼职, 实习, 不限');
|
|
86
|
+
}
|
|
87
|
+
function formatBossOnline(value) {
|
|
88
|
+
if (value === true)
|
|
89
|
+
return 'Y';
|
|
90
|
+
if (value === false)
|
|
91
|
+
return 'N';
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
65
94
|
cli({
|
|
66
95
|
site: 'boss',
|
|
67
96
|
name: 'search',
|
|
68
|
-
description: 'BOSS
|
|
97
|
+
description: 'BOSS直聘搜索职位(不带关键词时返回为你推荐职位)',
|
|
69
98
|
domain: 'www.zhipin.com',
|
|
70
99
|
strategy: Strategy.COOKIE,
|
|
71
100
|
navigateBefore: false,
|
|
72
101
|
browser: true,
|
|
73
102
|
args: [
|
|
74
|
-
{ name: 'query',
|
|
103
|
+
{ name: 'query', positional: true, help: 'Search keyword (optional, empty = recommended jobs)' },
|
|
75
104
|
{ name: 'city', default: '北京', help: 'City name or code (e.g. 杭州, 上海, 101010100)' },
|
|
76
|
-
{ name: 'experience', default: '', help: 'Experience:
|
|
105
|
+
{ name: 'experience', default: '', help: 'Experience: 在校生(实习)/应届生(校招)/经验不限/1年以内/1-3年/3-5年/5-10年/10年以上' },
|
|
77
106
|
{ name: 'degree', default: '', help: 'Degree: 大专/本科/硕士/博士' },
|
|
78
107
|
{ name: 'salary', default: '', help: 'Salary: 3K以下/3-5K/5-10K/10-15K/15-20K/20-30K/30-50K/50K以上' },
|
|
79
108
|
{ name: 'industry', default: '', help: 'Industry code or name (e.g. 100020, 互联网)' },
|
|
109
|
+
{ name: 'jobType', default: '', help: 'Job type: 全职/兼职/实习(不传=不限,混合校招与实习)' },
|
|
80
110
|
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
|
81
111
|
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
|
|
82
112
|
],
|
|
83
|
-
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
|
|
113
|
+
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'bossOnline', 'security_id', 'url'],
|
|
84
114
|
func: async (page, kwargs) => {
|
|
85
115
|
requirePage(page);
|
|
116
|
+
const query = String(kwargs.query ?? '').trim();
|
|
86
117
|
const cityCode = resolveCity(kwargs.city);
|
|
87
118
|
verbose('Navigating to set referrer context...');
|
|
88
|
-
await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(
|
|
119
|
+
await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(query)}&city=${cityCode}`);
|
|
89
120
|
await new Promise(r => setTimeout(r, 1000));
|
|
90
121
|
const expVal = resolveMap(kwargs.experience, EXP_MAP);
|
|
91
122
|
const degreeVal = resolveMap(kwargs.degree, DEGREE_MAP);
|
|
92
123
|
const salaryVal = resolveMap(kwargs.salary, SALARY_MAP);
|
|
93
124
|
const industryVal = resolveMap(kwargs.industry, INDUSTRY_MAP);
|
|
125
|
+
const jobTypeVal = resolveJobType(kwargs.jobType);
|
|
94
126
|
const limit = kwargs.limit || 15;
|
|
95
127
|
let currentPage = kwargs.page || 1;
|
|
96
128
|
let allJobs = [];
|
|
@@ -101,7 +133,7 @@ cli({
|
|
|
101
133
|
}
|
|
102
134
|
const qs = new URLSearchParams({
|
|
103
135
|
scene: '1',
|
|
104
|
-
query
|
|
136
|
+
query,
|
|
105
137
|
city: cityCode,
|
|
106
138
|
page: String(currentPage),
|
|
107
139
|
pageSize: '15',
|
|
@@ -114,6 +146,8 @@ cli({
|
|
|
114
146
|
qs.set('salary', salaryVal);
|
|
115
147
|
if (industryVal)
|
|
116
148
|
qs.set('industry', industryVal);
|
|
149
|
+
if (jobTypeVal)
|
|
150
|
+
qs.set('jobType', jobTypeVal);
|
|
117
151
|
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/search/joblist.json?${qs.toString()}`;
|
|
118
152
|
verbose(`Fetching page ${currentPage}... (current jobs: ${allJobs.length})`);
|
|
119
153
|
const data = await bossFetch(page, targetUrl);
|
|
@@ -135,6 +169,7 @@ cli({
|
|
|
135
169
|
degree: j.jobDegree,
|
|
136
170
|
skills: (j.skills || []).join(','),
|
|
137
171
|
boss: j.bossName + ' · ' + j.bossTitle,
|
|
172
|
+
bossOnline: formatBossOnline(j.bossOnline),
|
|
138
173
|
security_id: j.securityId || '',
|
|
139
174
|
url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
|
|
140
175
|
});
|
|
@@ -153,3 +188,9 @@ cli({
|
|
|
153
188
|
return allJobs;
|
|
154
189
|
},
|
|
155
190
|
});
|
|
191
|
+
export const __test__ = {
|
|
192
|
+
EXP_MAP,
|
|
193
|
+
resolveMap,
|
|
194
|
+
resolveJobType,
|
|
195
|
+
formatBossOnline,
|
|
196
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { __test__ } from './search.js';
|
|
5
|
+
import './search.js';
|
|
6
|
+
|
|
7
|
+
function createPageMock(response) {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
evaluate: vi.fn().mockResolvedValue(response),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('boss search', () => {
|
|
16
|
+
const command = getRegistry().get('boss/search');
|
|
17
|
+
|
|
18
|
+
it('keeps legacy 在校/应届 experience input compatible', () => {
|
|
19
|
+
expect(__test__.resolveMap('在校/应届', __test__.EXP_MAP)).toBe('108');
|
|
20
|
+
expect(__test__.resolveMap('应届', __test__.EXP_MAP)).toBe('102');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('fails fast on invalid jobType values', async () => {
|
|
24
|
+
expect(() => __test__.resolveJobType('外包')).toThrow(ArgumentError);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('accepts supported jobType labels and raw codes', () => {
|
|
28
|
+
expect(__test__.resolveJobType('全职')).toBe('1901');
|
|
29
|
+
expect(__test__.resolveJobType('实习')).toBe('1902');
|
|
30
|
+
expect(__test__.resolveJobType('兼职')).toBe('1903');
|
|
31
|
+
expect(__test__.resolveJobType('1902')).toBe('1902');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('keeps empty query empty and sends jobType filter to the API', async () => {
|
|
35
|
+
const page = createPageMock({
|
|
36
|
+
code: 0,
|
|
37
|
+
zpData: {
|
|
38
|
+
hasMore: false,
|
|
39
|
+
jobList: [
|
|
40
|
+
{
|
|
41
|
+
encryptJobId: 'abc',
|
|
42
|
+
securityId: 'sec',
|
|
43
|
+
jobName: '前端开发实习生',
|
|
44
|
+
salaryDesc: '150-200/天',
|
|
45
|
+
brandName: 'OpenCLI',
|
|
46
|
+
cityName: '北京',
|
|
47
|
+
areaDistrict: '海淀区',
|
|
48
|
+
businessDistrict: '',
|
|
49
|
+
jobExperience: '在校/应届',
|
|
50
|
+
jobDegree: '本科',
|
|
51
|
+
skills: ['JavaScript'],
|
|
52
|
+
bossName: '张三',
|
|
53
|
+
bossTitle: '技术负责人',
|
|
54
|
+
bossOnline: false,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const rows = await command.func(page, {
|
|
61
|
+
query: undefined,
|
|
62
|
+
city: '北京',
|
|
63
|
+
jobType: '实习',
|
|
64
|
+
limit: 1,
|
|
65
|
+
page: 1,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.zhipin.com/web/geek/job?query=&city=101010100');
|
|
69
|
+
const fetchScript = page.evaluate.mock.calls.at(-1)[0];
|
|
70
|
+
expect(fetchScript).toContain('query=');
|
|
71
|
+
expect(fetchScript).not.toContain('query=undefined');
|
|
72
|
+
expect(fetchScript).toContain('jobType=1902');
|
|
73
|
+
expect(rows[0]).toMatchObject({
|
|
74
|
+
name: '前端开发实习生',
|
|
75
|
+
bossOnline: 'N',
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
package/clis/boss/send.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
8
|
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList, typeAndSendMessage, } from './utils.js';
|
|
9
|
-
import { EmptyResultError,
|
|
9
|
+
import { EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
|
10
10
|
cli({
|
|
11
11
|
site: 'boss',
|
|
12
12
|
name: 'send',
|
|
@@ -30,12 +30,12 @@ cli({
|
|
|
30
30
|
const friendName = friend.name || '候选人';
|
|
31
31
|
const clicked = await clickCandidateInList(page, numericUid);
|
|
32
32
|
if (!clicked) {
|
|
33
|
-
throw
|
|
33
|
+
throw selectorError('聊天列表中的用户', '请确认聊天列表中有此人');
|
|
34
34
|
}
|
|
35
35
|
await page.wait({ time: 2 });
|
|
36
36
|
const sent = await typeAndSendMessage(page, kwargs.text);
|
|
37
37
|
if (!sent) {
|
|
38
|
-
throw
|
|
38
|
+
throw selectorError('消息输入框', '聊天页面 UI 可能已改变');
|
|
39
39
|
}
|
|
40
40
|
await page.wait({ time: 1 });
|
|
41
41
|
return [{ status: '✅ 发送成功', detail: `已向 ${friendName} 发送: ${kwargs.text}` }];
|
package/clis/chatgpt/image.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as os from 'node:os';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
3
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
4
5
|
import { saveBase64ToFile } from '@jackwener/opencli/utils';
|
|
6
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
5
7
|
import { getChatGPTVisibleImageUrls, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets } from './utils.js';
|
|
6
8
|
|
|
7
9
|
const CHATGPT_DOMAIN = 'chatgpt.com';
|
|
@@ -24,6 +26,22 @@ function displayPath(filePath) {
|
|
|
24
26
|
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
export function resolveOutputDir(value) {
|
|
30
|
+
const raw = String(value || '').trim();
|
|
31
|
+
if (!raw) return path.join(os.homedir(), 'Pictures', 'chatgpt');
|
|
32
|
+
if (raw === '~') return os.homedir();
|
|
33
|
+
if (raw.startsWith('~/')) return path.join(os.homedir(), raw.slice(2));
|
|
34
|
+
return path.resolve(raw);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function nextAvailablePath(dir, baseName, ext, existsSync = fs.existsSync) {
|
|
38
|
+
let candidate = path.join(dir, `${baseName}${ext}`);
|
|
39
|
+
for (let index = 1; existsSync(candidate); index += 1) {
|
|
40
|
+
candidate = path.join(dir, `${baseName}_${index}${ext}`);
|
|
41
|
+
}
|
|
42
|
+
return candidate;
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
async function currentChatGPTLink(page) {
|
|
28
46
|
const url = await page.evaluate('window.location.href').catch(() => '');
|
|
29
47
|
return typeof url === 'string' && url ? url : 'https://chatgpt.com';
|
|
@@ -41,13 +59,13 @@ export const imageCommand = cli({
|
|
|
41
59
|
timeoutSeconds: 240,
|
|
42
60
|
args: [
|
|
43
61
|
{ name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
|
|
44
|
-
{ name: 'op', default:
|
|
62
|
+
{ name: 'op', help: 'Output directory (default: ~/Pictures/chatgpt)' },
|
|
45
63
|
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
|
|
46
64
|
],
|
|
47
65
|
columns: ['status', 'file', 'link'],
|
|
48
66
|
func: async (page, kwargs) => {
|
|
49
67
|
const prompt = kwargs.prompt;
|
|
50
|
-
const outputDir = kwargs.op
|
|
68
|
+
const outputDir = resolveOutputDir(kwargs.op);
|
|
51
69
|
const skipDownloadRaw = kwargs.sd;
|
|
52
70
|
const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw);
|
|
53
71
|
const timeout = 120;
|
|
@@ -63,12 +81,23 @@ export const imageCommand = cli({
|
|
|
63
81
|
return [{ status: '⚠️ send-failed', file: '📁 -', link: `🔗 ${await currentChatGPTLink(page)}` }];
|
|
64
82
|
}
|
|
65
83
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
// ChatGPT briefly navigates to /c/{id} after sending, then may
|
|
85
|
+
// redirect back to the home page. Poll until we capture the /c/ URL.
|
|
86
|
+
let convUrl = '';
|
|
87
|
+
for (let ci = 0; ci < 10; ci++) {
|
|
88
|
+
const url = await currentChatGPTLink(page);
|
|
89
|
+
if (url.includes('/c/')) { convUrl = url; break; }
|
|
90
|
+
await page.wait(2);
|
|
91
|
+
}
|
|
92
|
+
if (!convUrl) {
|
|
93
|
+
convUrl = await currentChatGPTLink(page);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const urls = await waitForChatGPTImages(page, beforeUrls, timeout, convUrl);
|
|
97
|
+
const link = convUrl;
|
|
69
98
|
|
|
70
99
|
if (!urls.length) {
|
|
71
|
-
|
|
100
|
+
throw new EmptyResultError('chatgpt image', `No generated images were detected before timeout. Open ${link} and verify whether ChatGPT finished generating the image.`);
|
|
72
101
|
}
|
|
73
102
|
|
|
74
103
|
if (skipDownload) {
|
|
@@ -78,7 +107,7 @@ export const imageCommand = cli({
|
|
|
78
107
|
// Export and save images
|
|
79
108
|
const assets = await getChatGPTImageAssets(page, urls);
|
|
80
109
|
if (!assets.length) {
|
|
81
|
-
|
|
110
|
+
throw new CommandExecutionError('Failed to export generated ChatGPT image assets', `Open ${link} and verify the generated images are visible, then retry.`);
|
|
82
111
|
}
|
|
83
112
|
|
|
84
113
|
const stamp = Date.now();
|
|
@@ -88,7 +117,7 @@ export const imageCommand = cli({
|
|
|
88
117
|
const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, '');
|
|
89
118
|
const suffix = assets.length > 1 ? `_${index + 1}` : '';
|
|
90
119
|
const ext = extFromMime(asset.mimeType);
|
|
91
|
-
const filePath =
|
|
120
|
+
const filePath = nextAvailablePath(outputDir, `chatgpt_${stamp}${suffix}`, ext);
|
|
92
121
|
await saveBase64ToFile(base64, filePath);
|
|
93
122
|
results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` });
|
|
94
123
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as os from 'node:os';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
getChatGPTVisibleImageUrls: vi.fn(),
|
|
7
|
+
sendChatGPTMessage: vi.fn(),
|
|
8
|
+
waitForChatGPTImages: vi.fn(),
|
|
9
|
+
getChatGPTImageAssets: vi.fn(),
|
|
10
|
+
saveBase64ToFile: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('./utils.js', () => ({
|
|
14
|
+
getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls,
|
|
15
|
+
sendChatGPTMessage: mocks.sendChatGPTMessage,
|
|
16
|
+
waitForChatGPTImages: mocks.waitForChatGPTImages,
|
|
17
|
+
getChatGPTImageAssets: mocks.getChatGPTImageAssets,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@jackwener/opencli/utils', () => ({
|
|
21
|
+
saveBase64ToFile: mocks.saveBase64ToFile,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const { imageCommand, nextAvailablePath, resolveOutputDir } = await import('./image.js');
|
|
25
|
+
|
|
26
|
+
function createPage() {
|
|
27
|
+
return {
|
|
28
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
evaluate: vi.fn().mockResolvedValue('https://chatgpt.com/c/test-conversation'),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
mocks.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]);
|
|
37
|
+
mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true);
|
|
38
|
+
mocks.waitForChatGPTImages.mockReset().mockResolvedValue(['https://images.example/generated.png']);
|
|
39
|
+
mocks.getChatGPTImageAssets.mockReset().mockResolvedValue([{
|
|
40
|
+
url: 'https://images.example/generated.png',
|
|
41
|
+
dataUrl: 'data:image/png;base64,aGVsbG8=',
|
|
42
|
+
mimeType: 'image/png',
|
|
43
|
+
}]);
|
|
44
|
+
mocks.saveBase64ToFile.mockReset().mockResolvedValue(undefined);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('chatgpt image output paths', () => {
|
|
48
|
+
it('expands the default and explicit home-relative output directories', () => {
|
|
49
|
+
expect(resolveOutputDir()).toBe(path.join(os.homedir(), 'Pictures', 'chatgpt'));
|
|
50
|
+
expect(resolveOutputDir('~/tmp/chatgpt-images')).toBe(path.join(os.homedir(), 'tmp', 'chatgpt-images'));
|
|
51
|
+
expect(resolveOutputDir('~')).toBe(os.homedir());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('generates a non-overwriting file path when a timestamp collision exists', () => {
|
|
55
|
+
const dir = '/tmp/chatgpt';
|
|
56
|
+
const taken = new Set([
|
|
57
|
+
path.join(dir, 'chatgpt_123.png'),
|
|
58
|
+
path.join(dir, 'chatgpt_123_1.png'),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
expect(nextAvailablePath(dir, 'chatgpt_123', '.png', (file) => taken.has(file))).toBe(path.join(dir, 'chatgpt_123_2.png'));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('chatgpt image failure contracts', () => {
|
|
66
|
+
it('fails fast when image generation detection finds no new images', async () => {
|
|
67
|
+
mocks.waitForChatGPTImages.mockResolvedValue([]);
|
|
68
|
+
|
|
69
|
+
await expect(imageCommand.func(createPage(), {
|
|
70
|
+
prompt: 'cat',
|
|
71
|
+
op: '',
|
|
72
|
+
sd: false,
|
|
73
|
+
})).rejects.toMatchObject({
|
|
74
|
+
code: 'EMPTY_RESULT',
|
|
75
|
+
message: expect.stringContaining('chatgpt image returned no data'),
|
|
76
|
+
hint: expect.stringContaining('No generated images were detected'),
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('fails fast when generated image assets cannot be exported', async () => {
|
|
81
|
+
mocks.getChatGPTImageAssets.mockResolvedValue([]);
|
|
82
|
+
|
|
83
|
+
await expect(imageCommand.func(createPage(), {
|
|
84
|
+
prompt: 'cat',
|
|
85
|
+
op: '',
|
|
86
|
+
sd: false,
|
|
87
|
+
})).rejects.toMatchObject({
|
|
88
|
+
code: 'COMMAND_EXEC',
|
|
89
|
+
message: expect.stringContaining('Failed to export generated ChatGPT image assets'),
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|