@jackwener/opencli 1.8.0 → 1.8.1
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 +8 -49
- package/README.zh-CN.md +8 -52
- package/cli-manifest.json +1796 -191
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/suno/generate.js +5 -0
- package/clis/suno/generate.test.js +9 -0
- package/clis/suno/status.js +3 -2
- package/clis/suno/utils.js +33 -24
- package/clis/suno/utils.test.js +106 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-remove.js +1 -1
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +1 -1
- package/clis/twitter/search.test.js +35 -0
- package/clis/twitter/shared.js +11 -0
- package/clis/twitter/shared.test.js +37 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +80 -0
- package/clis/weread/search.js +17 -2
- package/clis/xiaohongshu/creator-note-detail.js +165 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
- package/clis/xiaohongshu/creator-notes.js +251 -2
- package/clis/xiaohongshu/creator-notes.test.js +79 -2
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/zhihu/answer-comments.js +2 -21
- package/clis/zhihu/answer-detail.js +2 -31
- package/clis/zhihu/collection.js +2 -14
- package/clis/zhihu/collection.test.js +4 -3
- package/clis/zhihu/question.js +1 -9
- package/clis/zhihu/question.test.js +2 -2
- package/clis/zhihu/search.js +1 -12
- package/clis/zhihu/search.test.js +2 -2
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.test.js +3 -1
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { createPageMock } from '../test-utils.js';
|
|
5
|
+
import './detail.js';
|
|
6
|
+
let cmd;
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
cmd = getRegistry().get('pixiv/detail');
|
|
9
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
10
|
+
});
|
|
11
|
+
describe('pixiv detail', () => {
|
|
12
|
+
it('throws ArgumentError on invalid illustration ID before navigation', async () => {
|
|
13
|
+
const page = createPageMock([]);
|
|
14
|
+
await expect(cmd.func(page, { id: 'xyz' })).rejects.toThrow(ArgumentError);
|
|
15
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
16
|
+
});
|
|
17
|
+
it('throws AuthRequiredError on 401', async () => {
|
|
18
|
+
const page = createPageMock([{ __httpError: 401 }]);
|
|
19
|
+
await expect(cmd.func(page, { id: '12345' })).rejects.toThrow(AuthRequiredError);
|
|
20
|
+
});
|
|
21
|
+
it('throws CommandExecutionError on 404', async () => {
|
|
22
|
+
const page = createPageMock([{ __httpError: 404 }]);
|
|
23
|
+
await expect(cmd.func(page, { id: '12345' })).rejects.toThrow(CommandExecutionError);
|
|
24
|
+
});
|
|
25
|
+
it('throws CommandExecutionError on non-auth HTTP failure', async () => {
|
|
26
|
+
const page = createPageMock([{ __httpError: 500 }]);
|
|
27
|
+
await expect(cmd.func(page, { id: '12345' })).rejects.toThrow(CommandExecutionError);
|
|
28
|
+
});
|
|
29
|
+
it('surfaces HTTP error body messages from pixivFetch', async () => {
|
|
30
|
+
const page = createPageMock([{ __httpError: 429, message: 'Too many requests' }]);
|
|
31
|
+
await expect(cmd.func(page, { id: '12345' })).rejects.toMatchObject({
|
|
32
|
+
code: 'COMMAND_EXEC',
|
|
33
|
+
message: expect.stringContaining('Too many requests'),
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
it('fails typed when the detail body lacks stable illustration identity fields', async () => {
|
|
37
|
+
const page = createPageMock([{ body: { illustId: '12345', illustTitle: 'Title' } }]);
|
|
38
|
+
await expect(cmd.func(page, { id: '12345' })).rejects.toMatchObject({
|
|
39
|
+
code: 'COMMAND_EXEC',
|
|
40
|
+
message: expect.stringContaining('malformed detail payload'),
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
it('fails typed when the detail body id does not match the requested illustration', async () => {
|
|
44
|
+
const page = createPageMock([
|
|
45
|
+
{
|
|
46
|
+
body: {
|
|
47
|
+
illustId: '99999',
|
|
48
|
+
illustTitle: 'Wrong',
|
|
49
|
+
userName: 'Artist',
|
|
50
|
+
userId: '99',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
await expect(cmd.func(page, { id: '12345' })).rejects.toMatchObject({
|
|
55
|
+
code: 'COMMAND_EXEC',
|
|
56
|
+
message: expect.stringContaining('malformed detail payload'),
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
it('returns detail row with mapped fields', async () => {
|
|
60
|
+
const page = createPageMock([
|
|
61
|
+
{
|
|
62
|
+
body: {
|
|
63
|
+
illustId: '12345',
|
|
64
|
+
illustTitle: 'Test Illust',
|
|
65
|
+
userName: 'Test Artist',
|
|
66
|
+
userId: '99',
|
|
67
|
+
illustType: 1,
|
|
68
|
+
pageCount: 4,
|
|
69
|
+
bookmarkCount: 200,
|
|
70
|
+
likeCount: 100,
|
|
71
|
+
viewCount: 5000,
|
|
72
|
+
tags: { tags: [{ tag: 'original' }, { tag: 'fantasy' }] },
|
|
73
|
+
createDate: '2025-01-15T12:00:00+09:00',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
]);
|
|
77
|
+
const result = await cmd.func(page, { id: '12345' });
|
|
78
|
+
expect(result).toEqual([{
|
|
79
|
+
illust_id: '12345',
|
|
80
|
+
title: 'Test Illust',
|
|
81
|
+
author: 'Test Artist',
|
|
82
|
+
user_id: '99',
|
|
83
|
+
type: 'manga',
|
|
84
|
+
pages: 4,
|
|
85
|
+
bookmarks: 200,
|
|
86
|
+
likes: 100,
|
|
87
|
+
views: 5000,
|
|
88
|
+
tags: 'original, fantasy',
|
|
89
|
+
created: '2025-01-15',
|
|
90
|
+
url: 'https://www.pixiv.net/artworks/12345',
|
|
91
|
+
}]);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/clis/pixiv/user.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { pixivFetch } from './utils.js';
|
|
4
|
+
|
|
5
|
+
function requireUserBody(body, uid) {
|
|
6
|
+
if (!body || Array.isArray(body) || typeof body !== 'object') {
|
|
7
|
+
throw new CommandExecutionError(`Pixiv user ${uid} returned malformed profile payload`);
|
|
8
|
+
}
|
|
9
|
+
const name = String(body.name ?? '').trim();
|
|
10
|
+
if (!name) {
|
|
11
|
+
throw new CommandExecutionError(`Pixiv user ${uid} returned malformed profile payload`);
|
|
12
|
+
}
|
|
13
|
+
return { ...body, name };
|
|
14
|
+
}
|
|
15
|
+
|
|
2
16
|
cli({
|
|
3
17
|
site: 'pixiv',
|
|
4
18
|
name: 'user',
|
|
@@ -6,7 +20,6 @@ cli({
|
|
|
6
20
|
description: 'View Pixiv artist profile',
|
|
7
21
|
domain: 'www.pixiv.net',
|
|
8
22
|
strategy: Strategy.COOKIE,
|
|
9
|
-
browser: true,
|
|
10
23
|
args: [
|
|
11
24
|
{ name: 'uid', required: true, positional: true, help: 'Pixiv user ID' },
|
|
12
25
|
],
|
|
@@ -21,34 +34,26 @@ cli({
|
|
|
21
34
|
'comment',
|
|
22
35
|
'url',
|
|
23
36
|
],
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
manga: typeof b.manga === 'object' ? Object.keys(b.manga).length : (b.manga || 0),
|
|
47
|
-
novels: typeof b.novels === 'object' ? Object.keys(b.novels).length : (b.novels || 0),
|
|
48
|
-
comment: (b.comment || '').slice(0, 80),
|
|
49
|
-
url: 'https://www.pixiv.net/users/' + uid
|
|
50
|
-
}];
|
|
51
|
-
})()
|
|
52
|
-
` },
|
|
53
|
-
],
|
|
37
|
+
func: async (page, kwargs) => {
|
|
38
|
+
const uid = String(kwargs.uid ?? '');
|
|
39
|
+
if (!/^\d+$/.test(uid)) {
|
|
40
|
+
throw new ArgumentError(`Invalid user ID: ${uid}`, 'Example: opencli pixiv user 123456');
|
|
41
|
+
}
|
|
42
|
+
const body = await pixivFetch(page, `/ajax/user/${uid}`, {
|
|
43
|
+
params: { full: 1 },
|
|
44
|
+
notFoundMsg: `User not found: ${uid}`,
|
|
45
|
+
});
|
|
46
|
+
const b = requireUserBody(body, uid);
|
|
47
|
+
return [{
|
|
48
|
+
user_id: uid,
|
|
49
|
+
name: b.name,
|
|
50
|
+
premium: b.premium ? 'Yes' : 'No',
|
|
51
|
+
following: b.following,
|
|
52
|
+
illusts: typeof b.illusts === 'object' ? Object.keys(b.illusts).length : (b.illusts || 0),
|
|
53
|
+
manga: typeof b.manga === 'object' ? Object.keys(b.manga).length : (b.manga || 0),
|
|
54
|
+
novels: typeof b.novels === 'object' ? Object.keys(b.novels).length : (b.novels || 0),
|
|
55
|
+
comment: (b.comment || '').slice(0, 80),
|
|
56
|
+
url: `https://www.pixiv.net/users/${uid}`,
|
|
57
|
+
}];
|
|
58
|
+
},
|
|
54
59
|
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { createPageMock } from '../test-utils.js';
|
|
5
|
+
import './user.js';
|
|
6
|
+
let cmd;
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
cmd = getRegistry().get('pixiv/user');
|
|
9
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
10
|
+
});
|
|
11
|
+
describe('pixiv user', () => {
|
|
12
|
+
it('throws ArgumentError on invalid user ID before navigation', async () => {
|
|
13
|
+
const page = createPageMock([]);
|
|
14
|
+
await expect(cmd.func(page, { uid: 'abc' })).rejects.toThrow(ArgumentError);
|
|
15
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
16
|
+
});
|
|
17
|
+
it('throws AuthRequiredError on 401', async () => {
|
|
18
|
+
const page = createPageMock([{ __httpError: 401 }]);
|
|
19
|
+
await expect(cmd.func(page, { uid: '11' })).rejects.toThrow(AuthRequiredError);
|
|
20
|
+
});
|
|
21
|
+
it('throws CommandExecutionError on 404', async () => {
|
|
22
|
+
const page = createPageMock([{ __httpError: 404 }]);
|
|
23
|
+
await expect(cmd.func(page, { uid: '11' })).rejects.toThrow(CommandExecutionError);
|
|
24
|
+
});
|
|
25
|
+
it('throws CommandExecutionError on non-auth HTTP failure', async () => {
|
|
26
|
+
const page = createPageMock([{ __httpError: 500 }]);
|
|
27
|
+
await expect(cmd.func(page, { uid: '11' })).rejects.toThrow(CommandExecutionError);
|
|
28
|
+
});
|
|
29
|
+
it('unwraps Browser Bridge envelopes around Pixiv API payloads', async () => {
|
|
30
|
+
const page = createPageMock([
|
|
31
|
+
{
|
|
32
|
+
session: 'site:pixiv',
|
|
33
|
+
data: {
|
|
34
|
+
body: {
|
|
35
|
+
name: 'Envelope Artist',
|
|
36
|
+
premium: false,
|
|
37
|
+
following: 0,
|
|
38
|
+
illusts: {},
|
|
39
|
+
manga: {},
|
|
40
|
+
novels: {},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
const result = await cmd.func(page, { uid: '12' });
|
|
46
|
+
expect(result[0]).toMatchObject({
|
|
47
|
+
user_id: '12',
|
|
48
|
+
name: 'Envelope Artist',
|
|
49
|
+
premium: 'No',
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it('surfaces Pixiv API error bodies instead of treating them as not found', async () => {
|
|
53
|
+
const page = createPageMock([{ error: true, message: 'rate limited' }]);
|
|
54
|
+
await expect(cmd.func(page, { uid: '11' })).rejects.toMatchObject({
|
|
55
|
+
code: 'COMMAND_EXEC',
|
|
56
|
+
message: 'rate limited',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
it('fails typed on malformed Pixiv API payloads', async () => {
|
|
60
|
+
const page = createPageMock([{ ok: true }]);
|
|
61
|
+
await expect(cmd.func(page, { uid: '11' })).rejects.toMatchObject({
|
|
62
|
+
code: 'COMMAND_EXEC',
|
|
63
|
+
message: expect.stringContaining('malformed API payload'),
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
it('fails typed when the user body lacks stable profile identity fields', async () => {
|
|
67
|
+
const page = createPageMock([{ body: { premium: false, following: 0 } }]);
|
|
68
|
+
await expect(cmd.func(page, { uid: '11' })).rejects.toMatchObject({
|
|
69
|
+
code: 'COMMAND_EXEC',
|
|
70
|
+
message: expect.stringContaining('malformed profile payload'),
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
it('returns profile row with computed counts for object-shaped illust fields', async () => {
|
|
74
|
+
const page = createPageMock([
|
|
75
|
+
{
|
|
76
|
+
body: {
|
|
77
|
+
name: 'Test Artist',
|
|
78
|
+
premium: true,
|
|
79
|
+
following: 42,
|
|
80
|
+
illusts: { '111': null, '222': null, '333': null },
|
|
81
|
+
manga: {},
|
|
82
|
+
novels: { '999': null },
|
|
83
|
+
comment: 'Hello world',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
const result = await cmd.func(page, { uid: '11' });
|
|
88
|
+
expect(result).toEqual([{
|
|
89
|
+
user_id: '11',
|
|
90
|
+
name: 'Test Artist',
|
|
91
|
+
premium: 'Yes',
|
|
92
|
+
following: 42,
|
|
93
|
+
illusts: 3,
|
|
94
|
+
manga: 0,
|
|
95
|
+
novels: 1,
|
|
96
|
+
comment: 'Hello world',
|
|
97
|
+
url: 'https://www.pixiv.net/users/11',
|
|
98
|
+
}]);
|
|
99
|
+
});
|
|
100
|
+
});
|
package/clis/pixiv/utils.js
CHANGED
|
@@ -7,6 +7,25 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
9
9
|
const PIXIV_DOMAIN = 'www.pixiv.net';
|
|
10
|
+
|
|
11
|
+
function unwrapEvaluateResult(payload) {
|
|
12
|
+
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
|
|
13
|
+
return payload.data;
|
|
14
|
+
}
|
|
15
|
+
return payload;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractPixivErrorMessage(payload) {
|
|
19
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
20
|
+
const candidates = [
|
|
21
|
+
payload.message,
|
|
22
|
+
payload.errorMessage,
|
|
23
|
+
payload.error?.message,
|
|
24
|
+
payload.error,
|
|
25
|
+
];
|
|
26
|
+
const found = candidates.find((value) => typeof value === 'string' && value.trim());
|
|
27
|
+
return found ? found.trim() : '';
|
|
28
|
+
}
|
|
10
29
|
/**
|
|
11
30
|
* Navigate to Pixiv (to attach cookies) then fetch a Pixiv Ajax API endpoint.
|
|
12
31
|
*
|
|
@@ -21,27 +40,57 @@ const PIXIV_DOMAIN = 'www.pixiv.net';
|
|
|
21
40
|
* @throws CommandExecutionError on 404 or other HTTP errors
|
|
22
41
|
*/
|
|
23
42
|
export async function pixivFetch(page, path, opts = {}) {
|
|
24
|
-
|
|
43
|
+
try {
|
|
44
|
+
await page.goto(`https://${PIXIV_DOMAIN}`);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new CommandExecutionError(`Pixiv navigation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
47
|
+
}
|
|
25
48
|
const qs = opts.params
|
|
26
49
|
? '?' + Object.entries(opts.params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
|
27
50
|
: '';
|
|
28
51
|
const url = `https://${PIXIV_DOMAIN}${path}${qs}`;
|
|
29
|
-
|
|
52
|
+
let data;
|
|
53
|
+
try {
|
|
54
|
+
data = unwrapEvaluateResult(await page.evaluate(`
|
|
30
55
|
(async () => {
|
|
31
56
|
const res = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
|
|
32
|
-
|
|
33
|
-
|
|
57
|
+
const text = await res.text();
|
|
58
|
+
let json = null;
|
|
59
|
+
if (text) {
|
|
60
|
+
try { json = JSON.parse(text); } catch {}
|
|
61
|
+
}
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
return {
|
|
64
|
+
__httpError: res.status,
|
|
65
|
+
message: json?.message || json?.errorMessage || json?.error?.message || (typeof json?.error === 'string' ? json.error : '') || text.slice(0, 200),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (!json) return { __malformed: true, message: 'invalid JSON' };
|
|
69
|
+
return json;
|
|
34
70
|
})()
|
|
35
|
-
`);
|
|
71
|
+
`));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new CommandExecutionError(`Pixiv request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
}
|
|
36
75
|
if (data?.__httpError) {
|
|
37
76
|
const status = data.__httpError;
|
|
38
77
|
if (status === 401 || status === 403) {
|
|
39
78
|
throw new AuthRequiredError(PIXIV_DOMAIN, 'Authentication required — please log in to Pixiv in Chrome');
|
|
40
79
|
}
|
|
80
|
+
const message = extractPixivErrorMessage(data);
|
|
41
81
|
if (status === 404) {
|
|
42
|
-
throw new CommandExecutionError(opts.notFoundMsg || `Pixiv resource not found (HTTP 404)`);
|
|
82
|
+
throw new CommandExecutionError(message || opts.notFoundMsg || `Pixiv resource not found (HTTP 404)`);
|
|
43
83
|
}
|
|
44
|
-
throw new CommandExecutionError(`Pixiv request failed (HTTP ${status})`);
|
|
84
|
+
throw new CommandExecutionError(message ? `Pixiv request failed (HTTP ${status}): ${message}` : `Pixiv request failed (HTTP ${status})`);
|
|
85
|
+
}
|
|
86
|
+
if (!data || Array.isArray(data) || typeof data !== 'object' || data.__malformed) {
|
|
87
|
+
throw new CommandExecutionError('Pixiv request returned malformed JSON payload');
|
|
88
|
+
}
|
|
89
|
+
if (data.error === true) {
|
|
90
|
+
throw new CommandExecutionError(extractPixivErrorMessage(data) || 'Pixiv API returned an error');
|
|
91
|
+
}
|
|
92
|
+
if (!('body' in data)) {
|
|
93
|
+
throw new CommandExecutionError('Pixiv request returned malformed API payload');
|
|
45
94
|
}
|
|
46
95
|
return data?.body;
|
|
47
96
|
}
|
package/clis/suno/generate.js
CHANGED
|
@@ -120,6 +120,11 @@ export const generateCommand = cli({
|
|
|
120
120
|
const title = titleSource.replace(/\s+/g, ' ').trim().slice(0, 60) || 'Untitled';
|
|
121
121
|
|
|
122
122
|
const session = await ensureSunoSession(page);
|
|
123
|
+
if (!session.planId) {
|
|
124
|
+
throw new CommandExecutionError(
|
|
125
|
+
`Suno generation needs a resolved plan id for the user_tier field, but billing/info did not surface one for this account (subscription_type=${session.planKey}). Verify the account is active at ${SUNO_URL}/account, then retry.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
123
128
|
const deviceId = session.deviceId;
|
|
124
129
|
const captcha = await checkSunoCaptcha(page, deviceId);
|
|
125
130
|
if (!captcha?.ok) {
|
|
@@ -94,6 +94,15 @@ describe('suno generate argument validation', () => {
|
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
it('refuses to submit when planId is null (free-tier without resolved user_tier) (#1704)', async () => {
|
|
98
|
+
mocks.ensureSunoSession.mockResolvedValue({ ...okSession, planId: null, planKey: 'free' });
|
|
99
|
+
await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
|
|
100
|
+
code: 'COMMAND_EXEC',
|
|
101
|
+
message: expect.stringContaining('plan id'),
|
|
102
|
+
});
|
|
103
|
+
expect(mocks.submitSunoGeneration).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
97
106
|
it('refuses to submit when credits are below the per-song minimum', async () => {
|
|
98
107
|
mocks.ensureSunoSession.mockResolvedValue({ ...okSession, totalCreditsAvailable: 5, breakdown: { ...okSession.breakdown, monthlyRemaining: 5 } });
|
|
99
108
|
await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
|
package/clis/suno/status.js
CHANGED
|
@@ -49,8 +49,9 @@ export const statusCommand = cli({
|
|
|
49
49
|
captcha = { required: true };
|
|
50
50
|
}
|
|
51
51
|
const b = session.breakdown;
|
|
52
|
-
// ensureSunoSession
|
|
53
|
-
//
|
|
52
|
+
// ensureSunoSession surfaces planKey derived from billing/info's
|
|
53
|
+
// subscription_type vs plans[] lookup; planId may be null for accounts
|
|
54
|
+
// whose plan cannot be resolved against plans[] (e.g., new schema fields).
|
|
54
55
|
return [{
|
|
55
56
|
Status: 'Connected',
|
|
56
57
|
Plan: session.planKey,
|
package/clis/suno/utils.js
CHANGED
|
@@ -136,6 +136,37 @@ function sunoHeadersJs(deviceId, extra = {}) {
|
|
|
136
136
|
// Session bootstrap.
|
|
137
137
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Parse studio-api-prod billing/info. Inlined into the page IIFE via
|
|
141
|
+
* toString() so the same parser runs in Node tests and the browser.
|
|
142
|
+
*/
|
|
143
|
+
export function parseSunoBillingInfo(data) {
|
|
144
|
+
const packCredits = (data?.credit_packs || []).reduce((s, p) => s + (p?.amount ?? p?.credits ?? 0), 0);
|
|
145
|
+
const monthlyRemaining = Math.max(0, (data?.monthly_limit ?? 0) - (data?.monthly_usage ?? 0));
|
|
146
|
+
const totalCreditsAvailable = typeof data?.total_credits_left === 'number'
|
|
147
|
+
? data.total_credits_left
|
|
148
|
+
: (data?.credits ?? 0) + packCredits + monthlyRemaining;
|
|
149
|
+
const plans = Array.isArray(data?.plans) ? data.plans : [];
|
|
150
|
+
const subscriptionKey = typeof data?.subscription_type === 'string' && data.subscription_type
|
|
151
|
+
? data.subscription_type
|
|
152
|
+
: null;
|
|
153
|
+
const currentPlan = subscriptionKey
|
|
154
|
+
? plans.find((p) => p?.plan_key === subscriptionKey)
|
|
155
|
+
: plans.find((p) => p?.plan_key === 'free');
|
|
156
|
+
return {
|
|
157
|
+
planId: currentPlan?.id || data?.plan?.id || null,
|
|
158
|
+
planKey: currentPlan?.plan_key || data?.plan?.plan_key || (subscriptionKey ?? 'free'),
|
|
159
|
+
totalCreditsAvailable,
|
|
160
|
+
breakdown: {
|
|
161
|
+
pack: data?.credits ?? 0,
|
|
162
|
+
purchasedPacks: packCredits,
|
|
163
|
+
monthlyRemaining,
|
|
164
|
+
monthlyLimit: data?.monthly_limit ?? 0,
|
|
165
|
+
monthlyUsed: data?.monthly_usage ?? 0,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
139
170
|
export async function ensureSunoSession(page) {
|
|
140
171
|
await page.goto(`${SUNO_URL}/me`, { settleMs: 2000 });
|
|
141
172
|
// OneTrust consent banner can block the page; dismiss it if present.
|
|
@@ -163,27 +194,8 @@ export async function ensureSunoSession(page) {
|
|
|
163
194
|
} catch (e) {
|
|
164
195
|
return { ok: false, error: 'Malformed billing/info JSON: ' + String(e).slice(0, 200) };
|
|
165
196
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// - monthly subscription : (monthly_limit - monthly_usage)
|
|
169
|
-
// - data.credit_packs[] : purchased packs not yet exhausted
|
|
170
|
-
// The web UI's "credits remaining" pill is the sum of all three.
|
|
171
|
-
const packCredits = (data?.credit_packs || []).reduce((s, p) => s + (p?.credits ?? 0), 0);
|
|
172
|
-
const monthlyRemaining = Math.max(0, (data?.monthly_limit ?? 0) - (data?.monthly_usage ?? 0));
|
|
173
|
-
const totalCreditsAvailable = (data?.credits ?? 0) + packCredits + monthlyRemaining;
|
|
174
|
-
return {
|
|
175
|
-
ok: true,
|
|
176
|
-
planId: data?.plan?.id || null,
|
|
177
|
-
planKey: data?.plan?.plan_key || null,
|
|
178
|
-
totalCreditsAvailable,
|
|
179
|
-
breakdown: {
|
|
180
|
-
pack: data?.credits ?? 0,
|
|
181
|
-
purchasedPacks: packCredits,
|
|
182
|
-
monthlyRemaining,
|
|
183
|
-
monthlyLimit: data?.monthly_limit ?? 0,
|
|
184
|
-
monthlyUsed: data?.monthly_usage ?? 0,
|
|
185
|
-
},
|
|
186
|
-
};
|
|
197
|
+
const parse = ${parseSunoBillingInfo.toString()};
|
|
198
|
+
return { ok: true, ...parse(data) };
|
|
187
199
|
} catch (e) {
|
|
188
200
|
return { ok: false, error: String(e).slice(0, 200) };
|
|
189
201
|
}
|
|
@@ -196,9 +208,6 @@ export async function ensureSunoSession(page) {
|
|
|
196
208
|
}
|
|
197
209
|
throw new CommandExecutionError(`Suno session check failed (${detail}).`);
|
|
198
210
|
}
|
|
199
|
-
if (!result.planId) {
|
|
200
|
-
throw new CommandExecutionError('Suno billing/info returned no plan id — cannot construct user_tier for generation request.');
|
|
201
|
-
}
|
|
202
211
|
return { ...result, deviceId };
|
|
203
212
|
}
|
|
204
213
|
|
package/clis/suno/utils.test.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
unwrapEvaluateResult,
|
|
17
17
|
pollSunoClips,
|
|
18
18
|
ensureSunoSession,
|
|
19
|
+
parseSunoBillingInfo,
|
|
19
20
|
} from './utils.js';
|
|
20
21
|
|
|
21
22
|
describe('suno utils — parseFormats', () => {
|
|
@@ -160,6 +161,84 @@ describe('suno utils — unwrapEvaluateResult', () => {
|
|
|
160
161
|
});
|
|
161
162
|
});
|
|
162
163
|
|
|
164
|
+
describe('suno utils — parseSunoBillingInfo', () => {
|
|
165
|
+
it('resolves the free-tier plan when subscription_type is false (#1704)', () => {
|
|
166
|
+
const parsed = parseSunoBillingInfo({
|
|
167
|
+
subscription_type: false,
|
|
168
|
+
credits: 0,
|
|
169
|
+
monthly_limit: 50,
|
|
170
|
+
monthly_usage: 10,
|
|
171
|
+
total_credits_left: 40,
|
|
172
|
+
credit_packs: [],
|
|
173
|
+
plans: [
|
|
174
|
+
{ id: '4497580c-f4eb-4f86-9f0e-960eb7c48d7d', name: 'Free Plan', plan_key: 'free', level: 0 },
|
|
175
|
+
{ id: '3eaebef3-ef46-446a-931c-3d50cd1514f1', name: 'Pro Plan', plan_key: 'pro', level: 10 },
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
expect(parsed.planId).toBe('4497580c-f4eb-4f86-9f0e-960eb7c48d7d');
|
|
179
|
+
expect(parsed.planKey).toBe('free');
|
|
180
|
+
expect(parsed.totalCreditsAvailable).toBe(40);
|
|
181
|
+
expect(parsed.breakdown.monthlyRemaining).toBe(40);
|
|
182
|
+
expect(parsed.breakdown.monthlyLimit).toBe(50);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('resolves the paid-tier plan when subscription_type matches a plan_key', () => {
|
|
186
|
+
const parsed = parseSunoBillingInfo({
|
|
187
|
+
subscription_type: 'pro',
|
|
188
|
+
credits: 0,
|
|
189
|
+
monthly_limit: 2500,
|
|
190
|
+
monthly_usage: 100,
|
|
191
|
+
total_credits_left: 2400,
|
|
192
|
+
credit_packs: [{ id: 'pack-1', amount: 500 }],
|
|
193
|
+
plans: [
|
|
194
|
+
{ id: 'free-uuid', name: 'Free Plan', plan_key: 'free', level: 0 },
|
|
195
|
+
{ id: 'pro-uuid', name: 'Pro Plan', plan_key: 'pro', level: 10 },
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
expect(parsed.planId).toBe('pro-uuid');
|
|
199
|
+
expect(parsed.planKey).toBe('pro');
|
|
200
|
+
expect(parsed.totalCreditsAvailable).toBe(2400);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('falls back to subscription_type as planKey when plans[] lookup misses', () => {
|
|
204
|
+
const parsed = parseSunoBillingInfo({
|
|
205
|
+
subscription_type: 'enterprise',
|
|
206
|
+
plans: [{ plan_key: 'pro' }, { plan_key: 'premier' }],
|
|
207
|
+
});
|
|
208
|
+
expect(parsed.planId).toBeNull();
|
|
209
|
+
expect(parsed.planKey).toBe('enterprise');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('returns planId null when plans[] is missing and there is no legacy plan field', () => {
|
|
213
|
+
const parsed = parseSunoBillingInfo({ subscription_type: false });
|
|
214
|
+
expect(parsed.planId).toBeNull();
|
|
215
|
+
expect(parsed.planKey).toBe('free');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('honours the legacy data.plan field when plans[] does not surface a match', () => {
|
|
219
|
+
const parsed = parseSunoBillingInfo({
|
|
220
|
+
subscription_type: false,
|
|
221
|
+
plan: { id: 'legacy-uuid', plan_key: 'legacy' },
|
|
222
|
+
});
|
|
223
|
+
expect(parsed.planId).toBe('legacy-uuid');
|
|
224
|
+
expect(parsed.planKey).toBe('legacy');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('sums credit_packs by amount and falls back to legacy credits field', () => {
|
|
228
|
+
const parsed = parseSunoBillingInfo({
|
|
229
|
+
subscription_type: false,
|
|
230
|
+
credits: 5,
|
|
231
|
+
monthly_limit: 0,
|
|
232
|
+
monthly_usage: 0,
|
|
233
|
+
credit_packs: [{ amount: 100 }, { credits: 50 }],
|
|
234
|
+
plans: [{ plan_key: 'free' }],
|
|
235
|
+
});
|
|
236
|
+
expect(parsed.breakdown.pack).toBe(5);
|
|
237
|
+
expect(parsed.breakdown.purchasedPacks).toBe(150);
|
|
238
|
+
expect(parsed.totalCreditsAvailable).toBe(155);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
163
242
|
describe('suno utils — ensureSunoSession typed failures', () => {
|
|
164
243
|
function createSessionPage(sessionCheckResult) {
|
|
165
244
|
const evaluate = async (script) => {
|
|
@@ -190,6 +269,33 @@ describe('suno utils — ensureSunoSession typed failures', () => {
|
|
|
190
269
|
error: 'Malformed billing/info JSON: Unexpected token <',
|
|
191
270
|
}))).rejects.toThrowError(CommandExecutionError);
|
|
192
271
|
});
|
|
272
|
+
|
|
273
|
+
it('resolves a free-tier session without throwing when subscription_type is false (#1704)', async () => {
|
|
274
|
+
const session = await ensureSunoSession(createSessionPage({
|
|
275
|
+
ok: true,
|
|
276
|
+
planId: '4497580c-f4eb-4f86-9f0e-960eb7c48d7d',
|
|
277
|
+
planKey: 'free',
|
|
278
|
+
planName: 'Free Plan',
|
|
279
|
+
totalCreditsAvailable: 40,
|
|
280
|
+
breakdown: { pack: 0, purchasedPacks: 0, monthlyRemaining: 40, monthlyLimit: 50, monthlyUsed: 10 },
|
|
281
|
+
}));
|
|
282
|
+
expect(session.planKey).toBe('free');
|
|
283
|
+
expect(session.planId).toBe('4497580c-f4eb-4f86-9f0e-960eb7c48d7d');
|
|
284
|
+
expect(session.totalCreditsAvailable).toBe(40);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('still resolves when planId is null so read commands work even on unparseable plan shapes', async () => {
|
|
288
|
+
const session = await ensureSunoSession(createSessionPage({
|
|
289
|
+
ok: true,
|
|
290
|
+
planId: null,
|
|
291
|
+
planKey: 'free',
|
|
292
|
+
planName: null,
|
|
293
|
+
totalCreditsAvailable: 0,
|
|
294
|
+
breakdown: { pack: 0, purchasedPacks: 0, monthlyRemaining: 0, monthlyLimit: 0, monthlyUsed: 0 },
|
|
295
|
+
}));
|
|
296
|
+
expect(session.planId).toBeNull();
|
|
297
|
+
expect(session.planKey).toBe('free');
|
|
298
|
+
});
|
|
193
299
|
});
|
|
194
300
|
|
|
195
301
|
describe('suno utils — model + format exports', () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ArgumentError, AuthRequiredError, selectorError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
1
|
+
import { ArgumentError, AuthRequiredError, selectorError, EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
3
|
import { normalizeTwitterScreenName, unwrapBrowserResult } from './shared.js';
|
|
4
4
|
|
|
@@ -161,7 +161,11 @@ cli({
|
|
|
161
161
|
const seen = new Set();
|
|
162
162
|
let sameCount = 0;
|
|
163
163
|
while (allFollowers.length < limit && sameCount < 3) {
|
|
164
|
-
const
|
|
164
|
+
const rawFollowers = await extractFollowersFromDOM(page);
|
|
165
|
+
if (!Array.isArray(rawFollowers)) {
|
|
166
|
+
throw new CommandExecutionError('Twitter followers extraction returned malformed rows');
|
|
167
|
+
}
|
|
168
|
+
const followers = rawFollowers;
|
|
165
169
|
const newFollowers = followers.filter(f => !seen.has(f.screen_name));
|
|
166
170
|
for (const f of newFollowers) {
|
|
167
171
|
seen.add(f.screen_name);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
-
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
4
|
import { __test__ } from './followers.js';
|
|
5
5
|
|
|
6
6
|
describe('twitter followers command', () => {
|
|
@@ -41,4 +41,22 @@ describe('twitter followers command', () => {
|
|
|
41
41
|
expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
|
|
42
42
|
expect(page.goto).not.toHaveBeenCalledWith('https://x.com/home/followers');
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
it('typed-fails instead of throwing "filter is not a function" when extractFollowersFromDOM returns a non-array', async () => {
|
|
46
|
+
const command = getRegistry().get('twitter/followers');
|
|
47
|
+
const page = {
|
|
48
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
50
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
evaluate: vi.fn(async (script) => {
|
|
52
|
+
const text = String(script);
|
|
53
|
+
if (text.includes('AppTabBar_Profile_Link')) return '/viewer';
|
|
54
|
+
if (text.includes('/followers') && text.includes('click')) return true;
|
|
55
|
+
if (text.includes('UserCell')) return undefined;
|
|
56
|
+
return undefined;
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
await expect(command.func(page, { user: 'someone', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
61
|
+
});
|
|
44
62
|
});
|