@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
@@ -1,4 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
3
  const { mockApiGet } = vi.hoisted(() => ({
3
4
  mockApiGet: vi.fn(),
4
5
  }));
@@ -15,11 +16,13 @@ describe('bilibili comments', () => {
15
16
  });
16
17
  it('resolves bvid to aid and fetches replies', async () => {
17
18
  mockApiGet
18
- .mockResolvedValueOnce({ data: { aid: 12345 } }) // view endpoint
19
+ .mockResolvedValueOnce({ code: 0, data: { aid: 12345 } }) // view endpoint
19
20
  .mockResolvedValueOnce({
21
+ code: 0,
20
22
  data: {
21
23
  replies: [
22
24
  {
25
+ rpid: 777,
23
26
  member: { uname: 'Alice' },
24
27
  content: { message: 'Great video!' },
25
28
  like: 42,
@@ -38,6 +41,7 @@ describe('bilibili comments', () => {
38
41
  expect(result).toEqual([
39
42
  {
40
43
  rank: 1,
44
+ rpid: '777',
41
45
  author: 'Alice',
42
46
  text: 'Great video!',
43
47
  likes: 42,
@@ -46,38 +50,93 @@ describe('bilibili comments', () => {
46
50
  },
47
51
  ]);
48
52
  });
53
+ it('fetches replies under a comment via /x/v2/reply/reply when --parent is given', async () => {
54
+ mockApiGet
55
+ .mockResolvedValueOnce({ code: 0, data: { aid: 12345 } }) // view endpoint
56
+ .mockResolvedValueOnce({
57
+ code: 0,
58
+ data: {
59
+ replies: [
60
+ {
61
+ rpid: 888,
62
+ member: { uname: 'AI视频小助理' },
63
+ content: { message: '视频总结:作者开了一家咖啡馆' },
64
+ like: 8,
65
+ rcount: 0,
66
+ ctime: 1700000000,
67
+ },
68
+ ],
69
+ },
70
+ });
71
+ const result = await command.func({}, { bvid: 'BV1WtAGzYEBm', parent: 777, limit: 5 });
72
+ expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
73
+ expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/reply', {
74
+ params: { oid: 12345, type: 1, root: 777, pn: 1, ps: 5 },
75
+ });
76
+ expect(result[0].author).toBe('AI视频小助理');
77
+ expect(result[0].rpid).toBe('888');
78
+ expect(result[0].text).toBe('视频总结:作者开了一家咖啡馆');
79
+ });
49
80
  it('throws when aid cannot be resolved', async () => {
50
- mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid
51
- await expect(command.func({}, { bvid: 'BVinvalid123', limit: 5 })).rejects.toThrow('Cannot resolve aid for bvid: BVinvalid123');
81
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: {} }); // no aid
82
+ await expect(command.func({}, { bvid: 'BVinvalid123', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
83
+ });
84
+ it('throws CommandExecutionError when replies is missing', async () => {
85
+ mockApiGet
86
+ .mockResolvedValueOnce({ code: 0, data: { aid: 99 } })
87
+ .mockResolvedValueOnce({ code: 0, data: {} }); // no replies key
88
+ await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
89
+ .rejects.toBeInstanceOf(CommandExecutionError);
90
+ });
91
+ it('rejects out-of-range limits instead of silently clamping', async () => {
92
+ await expect(command.func({}, { bvid: 'BV1xxx', limit: 999 }))
93
+ .rejects.toBeInstanceOf(ArgumentError);
94
+ expect(mockApiGet).not.toHaveBeenCalled();
52
95
  });
53
- it('returns empty array when replies is missing', async () => {
96
+ it('rejects invalid parent ids before fetching comments', async () => {
97
+ await expect(command.func({}, { bvid: 'BV1xxx', parent: 0, limit: 5 }))
98
+ .rejects.toBeInstanceOf(ArgumentError);
99
+ expect(mockApiGet).not.toHaveBeenCalled();
100
+ });
101
+ it('maps auth-like API errors to AuthRequiredError', async () => {
54
102
  mockApiGet
55
- .mockResolvedValueOnce({ data: { aid: 99 } })
56
- .mockResolvedValueOnce({ data: {} }); // no replies key
57
- const result = await command.func({}, { bvid: 'BV1xxx', limit: 5 });
58
- expect(result).toEqual([]);
103
+ .mockResolvedValueOnce({ code: -101, message: '账号未登录', data: null });
104
+ await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
105
+ .rejects.toBeInstanceOf(AuthRequiredError);
59
106
  });
60
- it('caps limit at 50', async () => {
107
+ it('throws EmptyResultError for explicit empty comments', async () => {
61
108
  mockApiGet
62
- .mockResolvedValueOnce({ data: { aid: 1 } })
63
- .mockResolvedValueOnce({ data: { replies: [] } });
64
- await command.func({}, { bvid: 'BV1xxx', limit: 999 });
65
- expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
66
- params: { oid: 1, type: 1, mode: 3, ps: 50 },
67
- signed: true,
68
- });
109
+ .mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
110
+ .mockResolvedValueOnce({ code: 0, data: { replies: [] } });
111
+ await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
112
+ .rejects.toBeInstanceOf(EmptyResultError);
69
113
  });
70
114
  it('collapses newlines in comment text', async () => {
71
115
  mockApiGet
72
- .mockResolvedValueOnce({ data: { aid: 1 } })
116
+ .mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
73
117
  .mockResolvedValueOnce({
118
+ code: 0,
74
119
  data: {
75
120
  replies: [
76
- { member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
121
+ { rpid: 123, member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
77
122
  ],
78
123
  },
79
124
  });
80
125
  const result = (await command.func({}, { bvid: 'BV1xxx', limit: 5 }));
81
126
  expect(result[0].text).toBe('line1 line2 line3');
82
127
  });
128
+ it('throws CommandExecutionError when a comment row lacks rpid', async () => {
129
+ mockApiGet
130
+ .mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
131
+ .mockResolvedValueOnce({
132
+ code: 0,
133
+ data: {
134
+ replies: [
135
+ { member: { uname: 'Bob' }, content: { message: 'hi' }, like: 0, rcount: 0, ctime: 0 },
136
+ ],
137
+ },
138
+ });
139
+ await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
140
+ .rejects.toBeInstanceOf(CommandExecutionError);
141
+ });
83
142
  });
@@ -1,11 +1,12 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
2
+ import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
3
  import { apiGet, resolveBvid } from './utils.js';
4
4
  cli({
5
5
  site: 'bilibili',
6
6
  name: 'subtitle',
7
7
  access: 'read',
8
8
  description: '获取 Bilibili 视频的字幕',
9
+ domain: 'www.bilibili.com',
9
10
  strategy: Strategy.COOKIE,
10
11
  args: [
11
12
  { name: 'bvid', required: true, positional: true, help: 'Bilibili 视频 BV ID(如 BV1xx411c7mD),或视频 URL / b23.tv 短链' },
@@ -16,53 +17,78 @@ cli({
16
17
  if (!page)
17
18
  throw new CommandExecutionError('Browser session required for bilibili subtitle');
18
19
  const bvid = await resolveBvid(kwargs.bvid);
19
- // 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
20
- await page.goto(`https://www.bilibili.com/video/${bvid}/`);
21
- // 2. 利用 __INITIAL_STATE__ 获取基础信息,拿 CID
22
- const cid = await page.evaluate(`(async () => {
23
- const state = window.__INITIAL_STATE__ || {};
24
- return state?.videoData?.cid;
25
- })()`);
20
+ // 1. 通过 view API 拿 cid。
21
+ // 以前的实现走 page.goto(/video/<bvid>) + window.__INITIAL_STATE__.videoData.cid,
22
+ // bangumi 绑定的 bvid(番剧/纪录片/电影/综艺)页面 state 不在 videoData 而在 epList,
23
+ // 导致 SELECTOR 错。view API 接受任何 bvid(UGC + PGC 都通),且不依赖 DOM 结构。
24
+ let view;
25
+ try {
26
+ view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
27
+ }
28
+ catch (err) {
29
+ throw new CommandExecutionError(`获取视频信息失败: ${err?.message || err}`);
30
+ }
31
+ if (view?.code !== 0) {
32
+ throw new CommandExecutionError(`获取视频信息失败: ${view?.message ?? 'unknown'} (${view?.code})`);
33
+ }
34
+ const cid = view?.data?.cid;
26
35
  if (!cid) {
27
- throw selectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
36
+ throw new CommandExecutionError(`无法从 view API 拿到 cid (bvid=${bvid})`);
37
+ }
38
+ // 2. 用带 Wbi 签名的 player/v2 拿字幕列表(之前 evaluate 里 fetch 因为没签名会 403)
39
+ let payload;
40
+ try {
41
+ payload = await apiGet(page, '/x/player/wbi/v2', {
42
+ params: { bvid, cid },
43
+ signed: true,
44
+ });
45
+ }
46
+ catch (err) {
47
+ throw new CommandExecutionError(`获取视频播放信息失败: ${err?.message || err}`);
48
+ }
49
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
50
+ throw new CommandExecutionError('获取到的视频播放信息对象不符合预期格式');
28
51
  }
29
- // 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
30
- // 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
31
- const payload = await apiGet(page, '/x/player/wbi/v2', {
32
- params: { bvid, cid },
33
- signed: true, // 开启 wbi_sign 自动签名
34
- });
35
52
  if (payload.code !== 0) {
36
53
  throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
37
54
  }
38
55
  const needLoginSubtitle = payload.data?.need_login_subtitle === true;
39
- const subtitles = payload.data?.subtitle?.subtitles || [];
56
+ const subtitles = payload.data?.subtitle?.subtitles;
57
+ if (!Array.isArray(subtitles)) {
58
+ throw new CommandExecutionError('获取到的字幕列表对象不符合数组格式');
59
+ }
40
60
  if (subtitles.length === 0) {
41
61
  if (needLoginSubtitle) {
42
62
  throw new AuthRequiredError('bilibili.com', 'Bilibili subtitles are hidden behind login for this video. Please log in to bilibili.com in Chrome and retry.');
43
63
  }
44
64
  throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
45
65
  }
46
- // 4. 选择目标字幕语言
66
+ // 3. 选择目标字幕语言
47
67
  const target = kwargs.lang
48
68
  ? subtitles.find((s) => s.lan === kwargs.lang) || subtitles[0]
49
69
  : subtitles[0];
50
- const targetSubUrl = target.subtitle_url;
51
- if (!targetSubUrl || targetSubUrl === '') {
70
+ if (!target || typeof target !== 'object' || !Object.hasOwn(target, 'subtitle_url')) {
71
+ throw new CommandExecutionError('字幕条目缺少 subtitle_url 字段');
72
+ }
73
+ const targetSubUrl = typeof target.subtitle_url === 'string' ? target.subtitle_url.trim() : '';
74
+ if (!targetSubUrl) {
52
75
  throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
53
76
  }
54
77
  const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
55
- // 5. 解析并拉取 CDN 的 JSON 文件
78
+ if (!/^https?:\/\//i.test(finalUrl)) {
79
+ throw new CommandExecutionError(`字幕 URL 非法: ${finalUrl}`);
80
+ }
81
+ // 4. 解析并拉取 CDN 的 JSON 文件
56
82
  const fetchJs = `
57
83
  (async () => {
58
84
  const url = ${JSON.stringify(finalUrl)};
59
85
  const res = await fetch(url);
60
86
  const text = await res.text();
61
-
87
+
62
88
  if (text.startsWith('<!DOCTYPE') || text.startsWith('<html')) {
63
89
  return { error: 'HTML', text: text.substring(0, 100), url };
64
90
  }
65
-
91
+
66
92
  try {
67
93
  const subJson = JSON.parse(text);
68
94
  // B站真实返回格式是 { font_size: 0.4, font_color: "#FFFFFF", background_alpha: 0.5, background_color: "#9C27B0", Stroke: "none", type: "json" , body: [{from: 0, to: 0, content: ""}] }
@@ -74,20 +100,39 @@ cli({
74
100
  }
75
101
  })()
76
102
  `;
77
- const items = await page.evaluate(fetchJs);
103
+ let items;
104
+ try {
105
+ items = await page.evaluate(fetchJs);
106
+ }
107
+ catch (err) {
108
+ throw new CommandExecutionError(`字幕获取失败: ${err?.message || err}`);
109
+ }
78
110
  if (items?.error) {
79
111
  throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
80
112
  }
81
- const finalItems = items?.data || [];
113
+ if (!items || typeof items !== 'object' || items.success !== true) {
114
+ throw new CommandExecutionError('字幕获取结果对象不符合预期格式');
115
+ }
116
+ const finalItems = items.data;
82
117
  if (!Array.isArray(finalItems)) {
83
118
  throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
84
119
  }
85
- // 6. 数据映射
86
- return finalItems.map((item, idx) => ({
87
- index: idx + 1,
88
- from: Number(item.from || 0).toFixed(2) + 's',
89
- to: Number(item.to || 0).toFixed(2) + 's',
90
- content: item.content
91
- }));
120
+ if (finalItems.length === 0) {
121
+ throw new EmptyResultError('bilibili subtitle', '字幕文件中没有字幕片段。');
122
+ }
123
+ // 5. 数据映射
124
+ return finalItems.map((item, idx) => {
125
+ const from = Number(item?.from);
126
+ const to = Number(item?.to);
127
+ if (!item || typeof item !== 'object' || !Number.isFinite(from) || !Number.isFinite(to)) {
128
+ throw new CommandExecutionError('字幕片段缺少有效 from/to 时间戳');
129
+ }
130
+ return {
131
+ index: idx + 1,
132
+ from: from.toFixed(2) + 's',
133
+ to: to.toFixed(2) + 's',
134
+ content: String(item.content ?? '')
135
+ };
136
+ });
92
137
  },
93
138
  });
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
2
+ import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
3
  const { mockApiGet } = vi.hoisted(() => ({
4
4
  mockApiGet: vi.fn(),
5
5
  }));
@@ -20,30 +20,177 @@ describe('bilibili subtitle', () => {
20
20
  page.goto.mockClear();
21
21
  page.evaluate.mockReset();
22
22
  });
23
+
24
+ // 帮助函数:第一发 apiGet(view)固定返 cid=123456 的 OK 响应
25
+ const mockViewOk = (cid = 123456) =>
26
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1GbXPBeEZm', cid } });
27
+
23
28
  it('throws AuthRequiredError when bilibili hides subtitles behind login', async () => {
24
- page.evaluate.mockResolvedValueOnce(123456);
29
+ mockViewOk();
25
30
  mockApiGet.mockResolvedValueOnce({
26
31
  code: 0,
27
32
  data: {
28
33
  need_login_subtitle: true,
29
- subtitle: {
30
- subtitles: [],
31
- },
34
+ subtitle: { subtitles: [] },
32
35
  },
33
36
  });
34
- await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toSatisfy((err) => err instanceof AuthRequiredError && /login|登录/i.test(err.message));
37
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toSatisfy(
38
+ (err) => err instanceof AuthRequiredError && /login|登录/i.test(err.message),
39
+ );
35
40
  });
41
+
36
42
  it('throws EmptyResultError when a video truly has no subtitles', async () => {
37
- page.evaluate.mockResolvedValueOnce(123456);
43
+ mockViewOk();
44
+ mockApiGet.mockResolvedValueOnce({
45
+ code: 0,
46
+ data: {
47
+ need_login_subtitle: false,
48
+ subtitle: { subtitles: [] },
49
+ },
50
+ });
51
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
52
+ });
53
+
54
+ it('throws CommandExecutionError when view API returns non-zero code', async () => {
55
+ // 番剧/地区限制等场景下 view API 也会返非零;之前路径走 SELECTOR 错,现在统一走 view 错
56
+ mockApiGet.mockResolvedValueOnce({ code: -404, message: '啥都木有' });
57
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
58
+ });
59
+
60
+ it('wraps view API fetch/json exceptions as CommandExecutionError', async () => {
61
+ mockApiGet.mockRejectedValueOnce(new SyntaxError('Unexpected token <'));
62
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
63
+ });
64
+
65
+ it('throws CommandExecutionError when view API succeeds but lacks cid', async () => {
66
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: { bvid: 'BV1GbXPBeEZm' /* no cid */ } });
67
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(/cid/);
68
+ });
69
+
70
+ it('throws CommandExecutionError when player subtitle payload is malformed', async () => {
71
+ mockViewOk();
72
+ mockApiGet.mockResolvedValueOnce({
73
+ code: 0,
74
+ data: {
75
+ need_login_subtitle: false,
76
+ subtitle: { subtitles: { lan: 'zh-CN' } },
77
+ },
78
+ });
79
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
80
+ });
81
+
82
+ it('throws CommandExecutionError when player API returns a non-object payload', async () => {
83
+ mockViewOk();
84
+ mockApiGet.mockResolvedValueOnce(null);
85
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
86
+
87
+ mockApiGet.mockReset();
88
+ mockViewOk();
89
+ mockApiGet.mockResolvedValueOnce([]);
90
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
91
+ });
92
+
93
+ it('throws AuthRequiredError only for explicit empty subtitle_url entries', async () => {
94
+ mockViewOk();
95
+ mockApiGet.mockResolvedValueOnce({
96
+ code: 0,
97
+ data: {
98
+ need_login_subtitle: false,
99
+ subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '' }] },
100
+ },
101
+ });
102
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(AuthRequiredError);
103
+ });
104
+
105
+ it('throws CommandExecutionError when subtitle entry lacks subtitle_url field', async () => {
106
+ mockViewOk();
107
+ mockApiGet.mockResolvedValueOnce({
108
+ code: 0,
109
+ data: {
110
+ need_login_subtitle: false,
111
+ subtitle: { subtitles: [{ lan: 'zh-CN' }] },
112
+ },
113
+ });
114
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
115
+ });
116
+
117
+ it('wraps subtitle file fetch exceptions as CommandExecutionError', async () => {
118
+ mockViewOk();
119
+ mockApiGet.mockResolvedValueOnce({
120
+ code: 0,
121
+ data: {
122
+ need_login_subtitle: false,
123
+ subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
124
+ },
125
+ });
126
+ page.evaluate.mockRejectedValueOnce(new Error('Failed to fetch'));
127
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
128
+ });
129
+
130
+ it('throws EmptyResultError when subtitle file has no cue rows', async () => {
131
+ mockViewOk();
132
+ mockApiGet.mockResolvedValueOnce({
133
+ code: 0,
134
+ data: {
135
+ need_login_subtitle: false,
136
+ subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
137
+ },
138
+ });
139
+ page.evaluate.mockResolvedValueOnce({ success: true, data: [] });
140
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
141
+ });
142
+
143
+ it('throws CommandExecutionError when subtitle cue rows have malformed time ranges', async () => {
144
+ mockViewOk();
145
+ mockApiGet.mockResolvedValueOnce({
146
+ code: 0,
147
+ data: {
148
+ need_login_subtitle: false,
149
+ subtitle: { subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }] },
150
+ },
151
+ });
152
+ page.evaluate.mockResolvedValueOnce({ success: true, data: [{ from: 'bad', to: 1.5, content: 'hello' }] });
153
+ await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(CommandExecutionError);
154
+ });
155
+
156
+ it('works for bangumi-bound bvid (PGC content) — same code path, view API returns cid + redirect_url', async () => {
157
+ // 回归保护:以前 page.goto(/video/<bvid>) 对 bangumi 走重定向,
158
+ // window.__INITIAL_STATE__.videoData 不存在 → SELECTOR 错。view API 不依赖页面结构,bangumi 同样能拿 cid。
159
+ mockApiGet.mockResolvedValueOnce({
160
+ code: 0,
161
+ data: {
162
+ bvid: 'BV1Py4y1D781',
163
+ cid: 267270412,
164
+ redirect_url: 'https://www.bilibili.com/bangumi/play/ep371508',
165
+ title: '【纪录片】灭绝的真相',
166
+ },
167
+ });
38
168
  mockApiGet.mockResolvedValueOnce({
39
169
  code: 0,
40
170
  data: {
41
171
  need_login_subtitle: false,
42
172
  subtitle: {
43
- subtitles: [],
173
+ subtitles: [{ lan: 'zh-CN', subtitle_url: '//example.com/sub.json' }],
44
174
  },
45
175
  },
46
176
  });
47
- await expect(command.func(page, { bvid: 'BV1GbXPBeEZm' })).rejects.toThrow(EmptyResultError);
177
+ page.evaluate.mockResolvedValueOnce({
178
+ success: true,
179
+ data: [
180
+ { from: 0, to: 1.5, content: 'hello' },
181
+ { from: 1.5, to: 3.2, content: 'world' },
182
+ ],
183
+ });
184
+ const out = await command.func(page, { bvid: 'BV1Py4y1D781' });
185
+ expect(out).toEqual([
186
+ { index: 1, from: '0.00s', to: '1.50s', content: 'hello' },
187
+ { index: 2, from: '1.50s', to: '3.20s', content: 'world' },
188
+ ]);
189
+ // 关键:不再依赖 page.goto,所有 cid 解析走 apiGet
190
+ expect(page.goto).not.toHaveBeenCalled();
191
+ // 第一发 apiGet 一定是 view 端点
192
+ const firstCall = mockApiGet.mock.calls[0];
193
+ expect(firstCall[1]).toBe('/x/web-interface/view');
194
+ expect(firstCall[2]?.params?.bvid).toBe('BV1Py4y1D781');
48
195
  });
49
196
  });
@@ -2,7 +2,7 @@
2
2
  * Bilibili shared helpers: WBI signing, authenticated fetch, nav data, UID resolution.
3
3
  */
4
4
  import https from 'node:https';
5
- import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
5
+ import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
6
6
  /**
7
7
  * Resolve Bilibili short URL / short code to BV ID.
8
8
  * Supports: BV1MV9NBtENN, XYzsqGa, b23.tv/XYzsqGa, https://b23.tv/XYzsqGa
@@ -12,7 +12,22 @@ export function resolveBvid(input) {
12
12
  if (/^BV[A-Za-z0-9]+$/i.test(trimmed)) {
13
13
  return Promise.resolve(trimmed);
14
14
  }
15
+ try {
16
+ const parsed = new URL(trimmed);
17
+ if (/(\.|^)bilibili\.com$/i.test(parsed.hostname)) {
18
+ const match = parsed.pathname.match(/\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
19
+ if (match) {
20
+ return Promise.resolve(match[1]);
21
+ }
22
+ }
23
+ }
24
+ catch {
25
+ // Non-URL inputs fall through to b23.tv short-code resolution.
26
+ }
15
27
  const shortCode = trimmed.replace(/^https?:\/\//, '').replace(/^(www\.)?b23\.tv\//, '');
28
+ if (!/^[A-Za-z0-9]+$/.test(shortCode)) {
29
+ return Promise.reject(new Error(`Cannot resolve BV ID from invalid b23.tv short code: ${trimmed}`));
30
+ }
16
31
  const url = 'https://b23.tv/' + shortCode;
17
32
  return new Promise((resolve, reject) => {
18
33
  const req = https.get(url, (res) => {
@@ -29,7 +44,7 @@ export function resolveBvid(input) {
29
44
  reject(new Error(`Cannot resolve BV ID from short URL: ${trimmed}`));
30
45
  });
31
46
  req.on('error', reject);
32
- req.setTimeout(5000, () => { req.destroy(); reject(new Error(`Timeout resolving short URL: ${trimmed}`)); });
47
+ req.setTimeout(4000, () => { req.destroy(); reject(new Error(`Timeout resolving short URL: ${trimmed}`)); });
33
48
  });
34
49
  }
35
50
  const MIXIN_KEY_ENC_TAB = [
@@ -104,6 +119,38 @@ export async function fetchJson(page, url) {
104
119
  }
105
120
  `);
106
121
  }
122
+ /**
123
+ * POST form-encoded params to a Bilibili API endpoint.
124
+ * Runs inside the logged-in browser context and auto-attaches the bili_jct CSRF token,
125
+ * which Bilibili requires on every authenticated write request.
126
+ */
127
+ export async function apiPost(page, path, opts = {}) {
128
+ const params = opts.params ?? {};
129
+ const stringified = Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]));
130
+ const paramsJs = JSON.stringify(stringified);
131
+ const urlJs = JSON.stringify(`https://api.bilibili.com${path}`);
132
+ return page.evaluate(`
133
+ async () => {
134
+ const csrf = (document.cookie.match(/bili_jct=([^;]+)/) || [])[1] || "";
135
+ const body = new URLSearchParams(${paramsJs});
136
+ body.set("csrf", csrf);
137
+ const res = await fetch(${urlJs}, {
138
+ method: "POST",
139
+ credentials: "include",
140
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
141
+ body: body.toString(),
142
+ });
143
+ // Bilibili write endpoints can return an HTML risk-control page (e.g. HTTP 412)
144
+ // instead of JSON. Surface that as a structured error rather than a parse crash.
145
+ const text = await res.text();
146
+ try {
147
+ return JSON.parse(text);
148
+ } catch {
149
+ return { code: -1, message: "Non-JSON response (HTTP " + res.status + "): " + text.slice(0, 200) };
150
+ }
151
+ }
152
+ `);
153
+ }
107
154
  export async function getSelfUid(page) {
108
155
  const nav = await getNavData(page);
109
156
  const mid = nav?.data?.mid;
@@ -119,8 +166,19 @@ export async function resolveUid(page, input) {
119
166
  params: { search_type: 'bili_user', keyword: input },
120
167
  signed: true,
121
168
  });
122
- const results = payload?.data?.result ?? [];
123
- if (results.length > 0)
124
- return String(results[0].mid);
169
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !payload.data || typeof payload.data !== 'object' || Array.isArray(payload.data) || !Object.hasOwn(payload.data, 'result')) {
170
+ throw new CommandExecutionError(`Bilibili user search returned malformed result for ${input}`);
171
+ }
172
+ const results = payload.data.result;
173
+ if (!Array.isArray(results)) {
174
+ throw new CommandExecutionError(`Bilibili user search returned malformed result for ${input}`);
175
+ }
176
+ if (results.length > 0) {
177
+ const mid = String(results[0]?.mid ?? '').trim();
178
+ if (!mid) {
179
+ throw new CommandExecutionError(`Bilibili user search returned malformed mid for ${input}`);
180
+ }
181
+ return mid;
182
+ }
125
183
  throw new EmptyResultError(`bilibili user search: ${input}`, 'User may not exist or username may have changed.');
126
184
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { resolveBvid } from './utils.js';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { resolveBvid, resolveUid } from './utils.js';
3
4
  describe('resolveBvid', () => {
4
5
  it('passes through a valid BV ID', async () => {
5
6
  expect(await resolveBvid('BV1MV9NBtENN')).toBe('BV1MV9NBtENN');
@@ -10,8 +11,51 @@ describe('resolveBvid', () => {
10
11
  it('handles non-string input via String() coercion', async () => {
11
12
  expect(await resolveBvid('BV123abc')).toBe('BV123abc');
12
13
  });
14
+ it('extracts BV IDs from bilibili video URLs', async () => {
15
+ expect(await resolveBvid('https://www.bilibili.com/video/BV1xx411c7mD/?spm_id_from=333.1007')).toBe('BV1xx411c7mD');
16
+ expect(await resolveBvid('https://m.bilibili.com/video/BV1Je9EBnEha')).toBe('BV1Je9EBnEha');
17
+ });
13
18
  it('rejects invalid input that cannot be resolved', async () => {
14
19
  // A random string that b23.tv won't resolve — should timeout or fail
15
20
  await expect(resolveBvid('not-a-valid-code-99999')).rejects.toThrow();
16
21
  });
17
22
  });
23
+
24
+ describe('resolveUid', () => {
25
+ function pageWithUserSearchResult(result) {
26
+ return {
27
+ evaluate: async (script) => {
28
+ if (String(script).includes('/x/web-interface/nav')) {
29
+ return {
30
+ data: {
31
+ wbi_img: {
32
+ img_url: 'https://i0.hdslb.com/bfs/wbi/abcdefghijklmnopqrstuvwxyz123456.png',
33
+ sub_url: 'https://i0.hdslb.com/bfs/wbi/ABCDEFGHIJKLMNOPQRSTUVWXYZ123456.png',
34
+ },
35
+ },
36
+ };
37
+ }
38
+ return result;
39
+ },
40
+ };
41
+ }
42
+
43
+ it('returns numeric uid input without searching', async () => {
44
+ expect(await resolveUid({}, '12345')).toBe('12345');
45
+ });
46
+
47
+ it('fails closed when user search payload lacks result', async () => {
48
+ await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: {} }), 'missing'))
49
+ .rejects.toBeInstanceOf(CommandExecutionError);
50
+ });
51
+
52
+ it('fails closed when user search result row lacks mid', async () => {
53
+ await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: { result: [{}] } }), 'missing-mid'))
54
+ .rejects.toBeInstanceOf(CommandExecutionError);
55
+ });
56
+
57
+ it('keeps explicit no-user result as EmptyResultError', async () => {
58
+ await expect(resolveUid(pageWithUserSearchResult({ code: 0, data: { result: [] } }), 'nobody'))
59
+ .rejects.toBeInstanceOf(EmptyResultError);
60
+ });
61
+ });