@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.
Files changed (153) hide show
  1. package/README.md +8 -49
  2. package/README.zh-CN.md +8 -52
  3. package/cli-manifest.json +1796 -191
  4. package/clis/_atlassian/shared.js +577 -0
  5. package/clis/_atlassian/shared.test.js +170 -0
  6. package/clis/bilibili/comment.js +125 -0
  7. package/clis/bilibili/comment.test.js +153 -0
  8. package/clis/bilibili/comments.js +116 -21
  9. package/clis/bilibili/comments.test.js +77 -18
  10. package/clis/bilibili/subtitle.js +76 -31
  11. package/clis/bilibili/subtitle.test.js +156 -9
  12. package/clis/bilibili/utils.js +63 -5
  13. package/clis/bilibili/utils.test.js +45 -1
  14. package/clis/chess/analyze.js +35 -0
  15. package/clis/chess/analyze.test.js +79 -0
  16. package/clis/chess/game.js +114 -0
  17. package/clis/chess/game.test.js +178 -0
  18. package/clis/chess/games.js +67 -0
  19. package/clis/chess/games.test.js +164 -0
  20. package/clis/chess/stats.js +32 -0
  21. package/clis/chess/stats.test.js +79 -0
  22. package/clis/chess/utils.js +170 -0
  23. package/clis/chess/utils.test.js +230 -0
  24. package/clis/confluence/commands.test.js +195 -0
  25. package/clis/confluence/create.js +39 -0
  26. package/clis/confluence/page.js +23 -0
  27. package/clis/confluence/search.js +34 -0
  28. package/clis/confluence/shared.js +173 -0
  29. package/clis/confluence/update.js +38 -0
  30. package/clis/douyin/hashtag.js +84 -23
  31. package/clis/douyin/hashtag.test.js +113 -0
  32. package/clis/geogebra/add-circle.js +46 -0
  33. package/clis/geogebra/add-line.js +35 -0
  34. package/clis/geogebra/add-point.js +27 -0
  35. package/clis/geogebra/add-polygon.js +25 -0
  36. package/clis/geogebra/eval.js +35 -0
  37. package/clis/geogebra/geogebra.test.js +175 -0
  38. package/clis/geogebra/hexagon.js +62 -0
  39. package/clis/geogebra/info.js +72 -0
  40. package/clis/geogebra/list.js +35 -0
  41. package/clis/geogebra/triangle.js +60 -0
  42. package/clis/geogebra/utils.js +271 -0
  43. package/clis/jira/attachments.js +28 -0
  44. package/clis/jira/commands.test.js +287 -0
  45. package/clis/jira/comments.js +28 -0
  46. package/clis/jira/issue.js +28 -0
  47. package/clis/jira/links.js +28 -0
  48. package/clis/jira/search.js +47 -0
  49. package/clis/jira/shared.js +256 -0
  50. package/clis/linkedin/job-detail.js +167 -0
  51. package/clis/linkedin/job-detail.test.js +38 -0
  52. package/clis/linkedin/jobs-preferences.js +113 -0
  53. package/clis/linkedin/jobs-preferences.test.js +43 -0
  54. package/clis/linkedin/post-analytics.js +74 -0
  55. package/clis/linkedin/post-analytics.test.js +40 -0
  56. package/clis/linkedin/posts-core.js +241 -0
  57. package/clis/linkedin/posts.js +22 -0
  58. package/clis/linkedin/posts.test.js +40 -0
  59. package/clis/linkedin/profile-analytics.js +104 -0
  60. package/clis/linkedin/profile-analytics.test.js +67 -0
  61. package/clis/linkedin/profile-experience.js +671 -0
  62. package/clis/linkedin/profile-experience.test.js +152 -0
  63. package/clis/linkedin/profile-projects.js +311 -0
  64. package/clis/linkedin/profile-projects.test.js +111 -0
  65. package/clis/linkedin/profile-read.js +148 -0
  66. package/clis/linkedin/profile-read.test.js +77 -0
  67. package/clis/linkedin/services-read.js +213 -0
  68. package/clis/linkedin/services-read.test.js +105 -0
  69. package/clis/linkedin/shared.js +124 -0
  70. package/clis/linkedin/timeline.js +14 -7
  71. package/clis/notebooklm/add-source.js +269 -0
  72. package/clis/notebooklm/add-source.test.js +97 -0
  73. package/clis/notebooklm/create.js +76 -0
  74. package/clis/notebooklm/create.test.js +58 -0
  75. package/clis/notebooklm/generate-audio.js +91 -0
  76. package/clis/notebooklm/generate-audio.test.js +63 -0
  77. package/clis/notebooklm/generate-slides.js +106 -0
  78. package/clis/notebooklm/generate-slides.test.js +75 -0
  79. package/clis/notebooklm/open.test.js +10 -10
  80. package/clis/notebooklm/rpc.js +20 -6
  81. package/clis/notebooklm/rpc.test.js +27 -1
  82. package/clis/notebooklm/utils.js +100 -24
  83. package/clis/notebooklm/utils.test.js +60 -1
  84. package/clis/notebooklm/write-note.js +103 -0
  85. package/clis/notebooklm/write-note.test.js +70 -0
  86. package/clis/pixiv/detail.js +41 -34
  87. package/clis/pixiv/detail.test.js +93 -0
  88. package/clis/pixiv/user.js +36 -31
  89. package/clis/pixiv/user.test.js +100 -0
  90. package/clis/pixiv/utils.js +56 -7
  91. package/clis/suno/generate.js +5 -0
  92. package/clis/suno/generate.test.js +9 -0
  93. package/clis/suno/status.js +3 -2
  94. package/clis/suno/utils.js +33 -24
  95. package/clis/suno/utils.test.js +106 -0
  96. package/clis/twitter/followers.js +6 -2
  97. package/clis/twitter/followers.test.js +19 -1
  98. package/clis/twitter/following.js +14 -5
  99. package/clis/twitter/following.test.js +29 -0
  100. package/clis/twitter/likes.js +12 -4
  101. package/clis/twitter/likes.test.js +26 -1
  102. package/clis/twitter/list-add.js +1 -1
  103. package/clis/twitter/list-remove.js +1 -1
  104. package/clis/twitter/notifications.js +4 -4
  105. package/clis/twitter/post.js +62 -4
  106. package/clis/twitter/post.test.js +35 -3
  107. package/clis/twitter/profile.js +81 -28
  108. package/clis/twitter/profile.test.js +113 -2
  109. package/clis/twitter/quote.js +9 -4
  110. package/clis/twitter/reply.js +13 -10
  111. package/clis/twitter/reply.test.js +41 -0
  112. package/clis/twitter/search.js +1 -1
  113. package/clis/twitter/search.test.js +35 -0
  114. package/clis/twitter/shared.js +11 -0
  115. package/clis/twitter/shared.test.js +37 -1
  116. package/clis/twitter/utils.js +53 -16
  117. package/clis/upwork/detail.js +132 -0
  118. package/clis/upwork/feed.js +109 -0
  119. package/clis/upwork/search.js +115 -0
  120. package/clis/upwork/upwork.test.js +566 -0
  121. package/clis/upwork/utils.js +323 -0
  122. package/clis/weread/book-search.js +438 -0
  123. package/clis/weread/book-search.test.js +242 -0
  124. package/clis/weread/search-regression.test.js +80 -0
  125. package/clis/weread/search.js +17 -2
  126. package/clis/xiaohongshu/creator-note-detail.js +165 -28
  127. package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
  128. package/clis/xiaohongshu/creator-notes.js +251 -2
  129. package/clis/xiaohongshu/creator-notes.test.js +79 -2
  130. package/clis/xiaohongshu/download.js +97 -39
  131. package/clis/xiaohongshu/download.test.js +201 -0
  132. package/clis/zhihu/answer-comments.js +2 -21
  133. package/clis/zhihu/answer-detail.js +2 -31
  134. package/clis/zhihu/collection.js +2 -14
  135. package/clis/zhihu/collection.test.js +4 -3
  136. package/clis/zhihu/question.js +1 -9
  137. package/clis/zhihu/question.test.js +2 -2
  138. package/clis/zhihu/search.js +1 -12
  139. package/clis/zhihu/search.test.js +2 -2
  140. package/clis/zhihu/text.js +29 -0
  141. package/clis/zhihu/text.test.js +24 -0
  142. package/dist/src/browser/network-cache.js +13 -1
  143. package/dist/src/browser/network-cache.test.js +17 -0
  144. package/dist/src/download/index.js +13 -1
  145. package/dist/src/download/index.test.js +23 -1
  146. package/dist/src/download/media-download.test.js +3 -1
  147. package/dist/src/download/progress.js +2 -2
  148. package/dist/src/download/progress.test.js +12 -1
  149. package/dist/src/output.js +11 -1
  150. package/dist/src/output.test.js +6 -0
  151. package/dist/src/registry.js +1 -0
  152. package/dist/src/registry.test.js +11 -0
  153. 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
+ });
@@ -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
- pipeline: [
25
- { navigate: 'https://www.pixiv.net' },
26
- { evaluate: `(async () => {
27
- const uid = \${{ args.uid | json }};
28
- const res = await fetch(
29
- 'https://www.pixiv.net/ajax/user/' + uid + '?full=1',
30
- { credentials: 'include' }
31
- );
32
- if (!res.ok) {
33
- if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome');
34
- if (res.status === 404) throw new Error('User not found: ' + uid);
35
- throw new Error('Pixiv request failed (HTTP ' + res.status + ')');
36
- }
37
- const data = await res.json();
38
- const b = data?.body;
39
- if (!b) throw new Error('User not found');
40
- return [{
41
- user_id: uid,
42
- name: b.name,
43
- premium: b.premium ? 'Yes' : 'No',
44
- following: b.following,
45
- illusts: typeof b.illusts === 'object' ? Object.keys(b.illusts).length : (b.illusts || 0),
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
+ });
@@ -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
- await page.goto(`https://${PIXIV_DOMAIN}`);
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
- const data = await page.evaluate(`
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
- if (!res.ok) return { __httpError: res.status };
33
- return await res.json();
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
  }
@@ -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({
@@ -49,8 +49,9 @@ export const statusCommand = cli({
49
49
  captcha = { required: true };
50
50
  }
51
51
  const b = session.breakdown;
52
- // ensureSunoSession guarantees planId; planKey is fetched from the same
53
- // billing/info response and is always populated for an active account.
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,
@@ -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
- // Suno tracks credits across three buckets:
167
- // - data.credits : leftover one-time pack credits (often 0)
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
 
@@ -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 followers = await extractFollowersFromDOM(page);
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
  });