@jackwener/opencli 1.7.21 → 1.8.0

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 (238) hide show
  1. package/README.md +31 -148
  2. package/README.zh-CN.md +38 -211
  3. package/cli-manifest.json +6423 -4260
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/boss/utils.js +17 -1
  24. package/clis/boss/utils.test.js +34 -0
  25. package/clis/chatgpt/envelope.test.js +108 -0
  26. package/clis/chatgpt/image.js +2 -2
  27. package/clis/chatgpt/image.test.js +6 -0
  28. package/clis/chatgpt/utils.js +148 -41
  29. package/clis/chatgpt/utils.test.js +92 -2
  30. package/clis/douyin/_shared/browser-fetch.js +44 -20
  31. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  32. package/clis/douyin/_shared/evaluate-result.js +16 -0
  33. package/clis/douyin/_shared/tos-upload.js +105 -69
  34. package/clis/douyin/_shared/vod-upload.js +212 -0
  35. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  36. package/clis/douyin/delete.js +137 -4
  37. package/clis/douyin/delete.test.js +90 -1
  38. package/clis/douyin/publish-upload-id.test.js +170 -0
  39. package/clis/douyin/publish.js +88 -42
  40. package/clis/douyin/user-videos.js +9 -2
  41. package/clis/douyin/user-videos.test.js +43 -0
  42. package/clis/flomo/memos.js +228 -0
  43. package/clis/flomo/memos.test.js +144 -0
  44. package/clis/gitee/search.js +2 -2
  45. package/clis/gitee/search.test.js +65 -0
  46. package/clis/jike/post.js +27 -17
  47. package/clis/jike/read.test.js +86 -0
  48. package/clis/jike/topic.js +32 -19
  49. package/clis/jike/user.js +33 -20
  50. package/clis/lesswrong/comments.js +1 -1
  51. package/clis/lesswrong/curated.js +1 -1
  52. package/clis/lesswrong/frontpage.js +1 -1
  53. package/clis/lesswrong/frontpage.test.js +37 -0
  54. package/clis/lesswrong/new.js +1 -1
  55. package/clis/lesswrong/read.js +1 -1
  56. package/clis/lesswrong/sequences.js +1 -1
  57. package/clis/lesswrong/shortform.js +1 -1
  58. package/clis/lesswrong/tag.js +1 -1
  59. package/clis/lesswrong/top-month.js +1 -1
  60. package/clis/lesswrong/top-week.js +1 -1
  61. package/clis/lesswrong/top-year.js +1 -1
  62. package/clis/lesswrong/top.js +1 -1
  63. package/clis/linkedin/connect.js +401 -0
  64. package/clis/linkedin/connect.test.js +213 -0
  65. package/clis/linkedin/inbox.js +234 -0
  66. package/clis/linkedin/inbox.test.js +152 -0
  67. package/clis/linkedin/people-search.js +262 -0
  68. package/clis/linkedin/people-search.test.js +216 -0
  69. package/clis/linkedin/safe-send.js +357 -0
  70. package/clis/linkedin/safe-send.test.js +204 -0
  71. package/clis/linkedin/salesnav-inbox.js +210 -0
  72. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  73. package/clis/linkedin/salesnav-message.js +360 -0
  74. package/clis/linkedin/salesnav-message.test.js +172 -0
  75. package/clis/linkedin/salesnav-search.js +186 -0
  76. package/clis/linkedin/salesnav-search.test.js +76 -0
  77. package/clis/linkedin/salesnav-thread.js +212 -0
  78. package/clis/linkedin/salesnav-thread.test.js +79 -0
  79. package/clis/linkedin/sent-invitations.js +92 -0
  80. package/clis/linkedin/sent-invitations.test.js +62 -0
  81. package/clis/linkedin/thread-snapshot.js +214 -0
  82. package/clis/linkedin/thread-snapshot.test.js +89 -0
  83. package/clis/linkedin-learning/course.js +138 -0
  84. package/clis/linkedin-learning/course.test.js +114 -0
  85. package/clis/linkedin-learning/search.js +155 -0
  86. package/clis/linkedin-learning/search.test.js +144 -0
  87. package/clis/linkedin-learning/trending.js +133 -0
  88. package/clis/linkedin-learning/trending.test.js +123 -0
  89. package/clis/powerchina/search.js +3 -3
  90. package/clis/powerchina/search.test.js +27 -1
  91. package/clis/reddit/extract-media.test.js +149 -0
  92. package/clis/reddit/frontpage.js +47 -9
  93. package/clis/reddit/frontpage.test.js +34 -0
  94. package/clis/reddit/home.js +31 -1
  95. package/clis/reddit/home.test.js +46 -3
  96. package/clis/reddit/hot.js +32 -1
  97. package/clis/reddit/hot.test.js +15 -1
  98. package/clis/reddit/popular.js +39 -1
  99. package/clis/reddit/popular.test.js +26 -0
  100. package/clis/reddit/saved.js +1 -1
  101. package/clis/reddit/search.js +38 -1
  102. package/clis/reddit/search.test.js +26 -0
  103. package/clis/reddit/subreddit.js +52 -7
  104. package/clis/reddit/subreddit.test.js +31 -0
  105. package/clis/reddit/subscribed.js +165 -0
  106. package/clis/reddit/subscribed.test.js +168 -0
  107. package/clis/reddit/upvoted.js +1 -1
  108. package/clis/suno/commands.test.js +188 -0
  109. package/clis/suno/download.js +140 -0
  110. package/clis/suno/download.test.js +151 -0
  111. package/clis/suno/generate.js +226 -0
  112. package/clis/suno/generate.test.js +243 -0
  113. package/clis/suno/list.js +79 -0
  114. package/clis/suno/status.js +62 -0
  115. package/clis/suno/utils.js +540 -0
  116. package/clis/suno/utils.test.js +223 -0
  117. package/clis/twitter/device-follow.js +193 -0
  118. package/clis/twitter/device-follow.test.js +287 -0
  119. package/clis/twitter/download.js +443 -73
  120. package/clis/twitter/download.test.js +457 -0
  121. package/clis/twitter/list-create.js +155 -0
  122. package/clis/twitter/list-create.test.js +169 -0
  123. package/clis/twitter/list-remove.js +12 -5
  124. package/clis/twitter/list-remove.test.js +74 -0
  125. package/clis/twitter/list-tweets.js +6 -2
  126. package/clis/twitter/list-tweets.test.js +41 -1
  127. package/clis/twitter/lists.js +31 -4
  128. package/clis/twitter/lists.test.js +152 -16
  129. package/clis/twitter/search.js +6 -2
  130. package/clis/twitter/search.test.js +6 -0
  131. package/clis/twitter/shared.js +144 -0
  132. package/clis/twitter/shared.test.js +429 -1
  133. package/clis/twitter/thread.js +10 -2
  134. package/clis/twitter/thread.test.js +58 -0
  135. package/clis/twitter/timeline.js +6 -2
  136. package/clis/twitter/timeline.test.js +2 -0
  137. package/clis/twitter/tweets.js +3 -2
  138. package/clis/twitter/tweets.test.js +1 -1
  139. package/clis/weibo/comments.js +3 -4
  140. package/clis/weibo/delete.js +172 -0
  141. package/clis/weibo/delete.test.js +94 -0
  142. package/clis/weibo/envelope.test.js +85 -0
  143. package/clis/weibo/favorites.js +4 -4
  144. package/clis/weibo/feed.js +3 -5
  145. package/clis/weibo/hot.js +3 -4
  146. package/clis/weibo/me.js +3 -5
  147. package/clis/weibo/post.js +3 -4
  148. package/clis/weibo/publish.js +37 -14
  149. package/clis/weibo/publish.test.js +14 -5
  150. package/clis/weibo/search.js +4 -3
  151. package/clis/weibo/user-posts.js +234 -0
  152. package/clis/weibo/user-posts.test.js +92 -0
  153. package/clis/weibo/user.js +3 -4
  154. package/clis/weibo/utils.js +34 -5
  155. package/clis/weibo/utils.test.js +36 -0
  156. package/clis/weread/search-regression.test.js +18 -11
  157. package/clis/weread/search.js +15 -7
  158. package/clis/weread-official/book.js +135 -0
  159. package/clis/weread-official/commands.test.js +385 -0
  160. package/clis/weread-official/discover.js +107 -0
  161. package/clis/weread-official/list-apis.js +95 -0
  162. package/clis/weread-official/notes.js +171 -0
  163. package/clis/weread-official/readdata.js +158 -0
  164. package/clis/weread-official/review.js +93 -0
  165. package/clis/weread-official/search.js +106 -0
  166. package/clis/weread-official/shelf.js +97 -0
  167. package/clis/weread-official/utils.js +293 -0
  168. package/clis/weread-official/utils.test.js +242 -0
  169. package/clis/wikipedia/trending.js +7 -3
  170. package/clis/wikipedia/trending.test.js +57 -0
  171. package/clis/xianyu/chat.js +24 -109
  172. package/clis/xianyu/chat.test.js +5 -0
  173. package/clis/xianyu/im.js +322 -0
  174. package/clis/xianyu/im.test.js +253 -0
  175. package/clis/xianyu/inbox.js +96 -0
  176. package/clis/xianyu/messages.js +91 -0
  177. package/clis/xianyu/reply.js +82 -0
  178. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  179. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  180. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  181. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  182. package/clis/xiaohongshu/creator-notes.js +2 -1
  183. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  184. package/clis/xiaohongshu/creator-stats.js +2 -1
  185. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  186. package/clis/xiaohongshu/delete-note.js +260 -0
  187. package/clis/xiaohongshu/delete-note.test.js +172 -0
  188. package/clis/xiaohongshu/publish.js +48 -8
  189. package/clis/xiaohongshu/publish.test.js +65 -10
  190. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  191. package/clis/xiaohongshu/user.js +27 -4
  192. package/clis/xiaoyuzhou/download.js +1 -1
  193. package/clis/xiaoyuzhou/transcript.js +1 -1
  194. package/clis/youdao/note.js +258 -0
  195. package/clis/youdao/note.test.js +99 -0
  196. package/clis/youtube/transcript.js +397 -24
  197. package/clis/youtube/transcript.test.js +196 -6
  198. package/clis/zhihu/answer-comments.js +299 -0
  199. package/clis/zhihu/answer-comments.test.js +287 -0
  200. package/clis/zhihu/answer-detail.js +12 -0
  201. package/clis/zhihu/answer-detail.test.js +8 -0
  202. package/clis/zhihu/collection.js +15 -2
  203. package/clis/zhihu/collection.test.js +46 -0
  204. package/clis/zhihu/download.js +1 -1
  205. package/clis/zhihu/question.js +42 -9
  206. package/clis/zhihu/question.test.js +111 -9
  207. package/clis/zhihu/search.js +206 -43
  208. package/clis/zhihu/search.test.js +198 -0
  209. package/dist/src/browser/errors.js +4 -2
  210. package/dist/src/browser/errors.test.js +6 -0
  211. package/dist/src/browser/page.js +30 -4
  212. package/dist/src/browser/page.test.js +42 -0
  213. package/dist/src/browser/utils.d.ts +1 -1
  214. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  215. package/dist/src/cli-argv-preprocess.js +138 -0
  216. package/dist/src/cli-argv-preprocess.test.js +79 -0
  217. package/dist/src/cli.js +1 -1
  218. package/dist/src/convention-audit.js +15 -8
  219. package/dist/src/convention-audit.test.js +21 -0
  220. package/dist/src/download/media-download.js +15 -2
  221. package/dist/src/download/media-download.test.d.ts +1 -0
  222. package/dist/src/download/media-download.test.js +110 -0
  223. package/dist/src/electron-apps.js +1 -1
  224. package/dist/src/electron-apps.test.js +7 -2
  225. package/dist/src/errors.d.ts +17 -0
  226. package/dist/src/errors.js +22 -0
  227. package/dist/src/external-clis.yaml +20 -0
  228. package/dist/src/external.d.ts +6 -1
  229. package/dist/src/external.test.js +19 -0
  230. package/dist/src/main.js +14 -2
  231. package/dist/src/utils.d.ts +43 -0
  232. package/dist/src/utils.js +97 -0
  233. package/dist/src/utils.test.d.ts +1 -0
  234. package/dist/src/utils.test.js +155 -0
  235. package/package.json +8 -2
  236. package/scripts/silent-column-drop-baseline.json +0 -52
  237. package/scripts/typed-error-lint-baseline.json +28 -380
  238. package/clis/slock/_utils.js +0 -12
@@ -1,10 +1,39 @@
1
1
  /**
2
2
  * Shared Weibo utilities — uid extraction.
3
3
  */
4
- import { AuthRequiredError } from '@jackwener/opencli/errors';
4
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
5
+ /**
6
+ * `page.evaluate` may return either the raw IIFE value or a
7
+ * `{ session, data }` envelope depending on the browser-bridge version.
8
+ * Adapter code that inspected the payload directly (e.g. `Array.isArray`,
9
+ * truthiness checks on uid strings) silently received the envelope wrapper
10
+ * instead of the inner value. This helper normalizes both shapes so callers
11
+ * can keep their existing checks unchanged.
12
+ */
13
+ export function unwrapEvaluateResult(payload) {
14
+ if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
15
+ return payload.data;
16
+ }
17
+ return payload;
18
+ }
19
+ export function requireArrayEvaluateResult(payload, label) {
20
+ if (!Array.isArray(payload)) {
21
+ if (payload && typeof payload === 'object' && 'error' in payload) {
22
+ throw new CommandExecutionError(`${label}: ${String(payload.error)}`);
23
+ }
24
+ throw new CommandExecutionError(`${label} returned malformed extraction payload`);
25
+ }
26
+ return payload;
27
+ }
28
+ export function requireObjectEvaluateResult(payload, label) {
29
+ if (!payload || Array.isArray(payload) || typeof payload !== 'object') {
30
+ throw new CommandExecutionError(`${label} returned malformed extraction payload`);
31
+ }
32
+ return payload;
33
+ }
5
34
  /** Get the currently logged-in user's uid from Vue store or config API. */
6
35
  export async function getSelfUid(page) {
7
- const uid = await page.evaluate(`
36
+ const uid = unwrapEvaluateResult(await page.evaluate(`
8
37
  (() => {
9
38
  const app = document.querySelector('#app')?.__vue_app__;
10
39
  const store = app?.config?.globalProperties?.$store;
@@ -12,18 +41,18 @@ export async function getSelfUid(page) {
12
41
  if (uid) return String(uid);
13
42
  return null;
14
43
  })()
15
- `);
44
+ `));
16
45
  if (uid)
17
46
  return uid;
18
47
  // Fallback: config API
19
- const config = await page.evaluate(`
48
+ const config = unwrapEvaluateResult(await page.evaluate(`
20
49
  (async () => {
21
50
  const resp = await fetch('/ajax/config/get_config', {credentials: 'include'});
22
51
  if (!resp.ok) return null;
23
52
  const data = await resp.json();
24
53
  return data.ok && data.data?.uid ? String(data.data.uid) : null;
25
54
  })()
26
- `);
55
+ `));
27
56
  if (config)
28
57
  return config;
29
58
  throw new AuthRequiredError('weibo.com');
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { requireArrayEvaluateResult, requireObjectEvaluateResult, unwrapEvaluateResult } from './utils.js';
4
+
5
+ describe('unwrapEvaluateResult (browser-bridge envelope normalization)', () => {
6
+ it('returns the raw array unchanged when payload is already an array', () => {
7
+ const arr = [{ id: '1' }, { id: '2' }];
8
+ expect(unwrapEvaluateResult(arr)).toBe(arr);
9
+ });
10
+ it('unwraps { session, data: [...] } envelope to the inner array', () => {
11
+ const arr = [{ id: '1' }];
12
+ const env = { session: 'site:weibo:abc', data: arr };
13
+ expect(unwrapEvaluateResult(env)).toBe(arr);
14
+ });
15
+ it('unwraps primitive data (e.g. uid string) from Browser Bridge envelopes', () => {
16
+ expect(unwrapEvaluateResult({ session: 'site:weibo:abc', data: '1234567890' })).toBe('1234567890');
17
+ });
18
+ it('unwraps null payload data so getSelfUid fallback can trigger', () => {
19
+ expect(unwrapEvaluateResult({ session: 'site:weibo:abc', data: null })).toBe(null);
20
+ });
21
+ it('passes non-envelope objects through unchanged (e.g. profile result)', () => {
22
+ const obj = { screen_name: 'alice', uid: '42' };
23
+ expect(unwrapEvaluateResult(obj)).toBe(obj);
24
+ });
25
+ it('handles null and undefined safely', () => {
26
+ expect(unwrapEvaluateResult(null)).toBe(null);
27
+ expect(unwrapEvaluateResult(undefined)).toBe(undefined);
28
+ });
29
+ it('keeps malformed array/object payloads as typed command failures after unwrap', () => {
30
+ expect(requireArrayEvaluateResult([{ id: '1' }], 'weibo feed')).toEqual([{ id: '1' }]);
31
+ expect(() => requireArrayEvaluateResult({ error: 'API error' }, 'weibo feed')).toThrow(CommandExecutionError);
32
+ expect(() => requireArrayEvaluateResult({ error: 'API error' }, 'weibo feed')).toThrow('weibo feed: API error');
33
+ expect(requireObjectEvaluateResult({ uid: '42' }, 'weibo me')).toEqual({ uid: '42' });
34
+ expect(() => requireObjectEvaluateResult([{ uid: '42' }], 'weibo me')).toThrow(CommandExecutionError);
35
+ });
36
+ });
@@ -1,5 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
4
  import './search.js';
4
5
  describe('weread/search regression', () => {
5
6
  beforeEach(() => {
@@ -146,7 +147,7 @@ describe('weread/search regression', () => {
146
147
  },
147
148
  ]);
148
149
  });
149
- it('falls back to empty urls when the search html request fails', async () => {
150
+ it('surfaces search html request failures instead of emitting empty urls', async () => {
150
151
  const command = getRegistry().get('weread/search');
151
152
  expect(command?.func).toBeTypeOf('function');
152
153
  const fetchMock = vi.fn()
@@ -166,16 +167,22 @@ describe('weread/search regression', () => {
166
167
  })
167
168
  .mockRejectedValueOnce(new Error('network timeout'));
168
169
  vi.stubGlobal('fetch', fetchMock);
169
- const result = await command.func({ query: 'deep work', limit: 5 });
170
- expect(result).toEqual([
171
- {
172
- rank: 1,
173
- title: 'Deep Work',
174
- author: 'Cal Newport',
175
- bookId: 'abc123',
176
- url: '',
177
- },
178
- ]);
170
+ await expect(command.func({ query: 'deep work', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
171
+ });
172
+ it('throws EmptyResultError when the public search API returns no books', async () => {
173
+ const command = getRegistry().get('weread/search');
174
+ expect(command?.func).toBeTypeOf('function');
175
+ const fetchMock = vi.fn()
176
+ .mockResolvedValueOnce({
177
+ ok: true,
178
+ json: () => Promise.resolve({ books: [] }),
179
+ })
180
+ .mockResolvedValueOnce({
181
+ ok: true,
182
+ text: () => Promise.resolve('<html></html>'),
183
+ });
184
+ vi.stubGlobal('fetch', fetchMock);
185
+ await expect(command.func({ query: 'definitely-missing-book', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
179
186
  });
180
187
  it('binds reader urls with title and author instead of title alone', async () => {
181
188
  const command = getRegistry().get('weread/search');
@@ -1,4 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
3
  import { fetchWebApi, WEREAD_UA, WEREAD_WEB_ORIGIN } from './utils.js';
3
4
  function decodeHtmlText(value) {
4
5
  return value
@@ -84,18 +85,19 @@ function resolveSearchResultUrl(params) {
84
85
  async function loadSearchHtmlEntries(query) {
85
86
  const url = new URL('/web/search/books', WEREAD_WEB_ORIGIN);
86
87
  url.searchParams.set('keyword', query);
87
- let html = '';
88
+ let resp;
88
89
  try {
89
- const resp = await fetch(url.toString(), {
90
+ resp = await fetch(url.toString(), {
90
91
  headers: { 'User-Agent': WEREAD_UA },
91
92
  });
92
- if (!resp.ok)
93
- return [];
94
- html = await resp.text();
95
93
  }
96
- catch {
97
- return [];
94
+ catch (error) {
95
+ throw new CommandExecutionError(`Failed to fetch WeRead search page: ${error instanceof Error ? error.message : String(error)}`);
98
96
  }
97
+ if (!resp.ok) {
98
+ throw new CommandExecutionError(`WeRead search page request failed: HTTP ${resp.status}`);
99
+ }
100
+ const html = await resp.text();
99
101
  const items = Array.from(html.matchAll(/<li[^>]*class="wr_bookList_item"[^>]*>([\s\S]*?)<\/li>/g));
100
102
  return items.map((match) => {
101
103
  const chunk = match[1];
@@ -131,6 +133,12 @@ cli({
131
133
  loadSearchHtmlEntries(String(args.query ?? '')),
132
134
  ]);
133
135
  const books = data?.books ?? [];
136
+ if (!Array.isArray(books)) {
137
+ throw new CommandExecutionError('WeRead search API returned an unreadable books payload');
138
+ }
139
+ if (books.length === 0) {
140
+ throw new EmptyResultError('weread search', `No books were returned for query ${args.query}.`);
141
+ }
134
142
  const { exactQueues, titleOnlyQueues } = buildSearchUrlQueues(htmlEntries);
135
143
  const apiIdentityCounts = countSearchIdentities(books.map((item) => ({
136
144
  title: item.bookInfo?.title ?? '',
@@ -0,0 +1,135 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import {
3
+ callGateway,
4
+ emptyResult,
5
+ formatDate,
6
+ formatDuration,
7
+ formatRating,
8
+ makeDeepLink,
9
+ requireBookId,
10
+ truncate,
11
+ } from './utils.js';
12
+
13
+ /**
14
+ * 3-in-1 book lookup: `/book/info` always runs; `/book/chapterinfo` and
15
+ * `/book/getprogress` are opt-out via `--no-chapters` / `--no-progress`
16
+ * so users can shave 1-2 gateway calls when only a single section is needed.
17
+ *
18
+ * Output is a flat row list with a `section` column so table/json/yaml
19
+ * consumers can filter without ad-hoc joins:
20
+ * - section=info — single row, basic metadata
21
+ * - section=chapter — one row per chapter (level/title/wordCount)
22
+ * - section=progress — single row, progress % + cumulative read time
23
+ */
24
+ cli({
25
+ site: 'weread-official',
26
+ name: 'book',
27
+ access: 'read',
28
+ description: 'Show WeRead book metadata, chapters, and reading progress',
29
+ domain: 'weread.qq.com',
30
+ strategy: Strategy.PUBLIC,
31
+ browser: false,
32
+ args: [
33
+ { name: 'bookId', positional: true, required: true, help: 'WeRead bookId (from `weread-official search`)' },
34
+ { name: 'no-chapters', type: 'boolean', default: false, help: 'Skip /book/chapterinfo call' },
35
+ { name: 'no-progress', type: 'boolean', default: false, help: 'Skip /book/getprogress call' },
36
+ ],
37
+ columns: ['section', 'idx', 'key', 'value', 'link'],
38
+ func: async (args) => {
39
+ const bookId = requireBookId(args.bookId);
40
+
41
+ const tasks = [callGateway('/book/info', { bookId })];
42
+ const want = {
43
+ chapters: !args['no-chapters'],
44
+ progress: !args['no-progress'],
45
+ };
46
+ if (want.chapters) tasks.push(callGateway('/book/chapterinfo', { bookId }));
47
+ if (want.progress) tasks.push(callGateway('/book/getprogress', { bookId }));
48
+
49
+ const results = await Promise.all(tasks);
50
+ let cursor = 0;
51
+ const info = results[cursor++];
52
+ const chapters = want.chapters ? results[cursor++] : null;
53
+ const progress = want.progress ? results[cursor++] : null;
54
+
55
+ const rows = [];
56
+
57
+ // ── info section ────────────────────────────────────────────────────
58
+ const infoPairs = [
59
+ ['bookId', String(info?.bookId ?? bookId)],
60
+ ['title', String(info?.title ?? '')],
61
+ ['author', String(info?.author ?? '')],
62
+ ['translator', String(info?.translator ?? '')],
63
+ ['category', String(info?.category ?? '')],
64
+ ['publisher', String(info?.publisher ?? '')],
65
+ ['publishTime', String(info?.publishTime ?? '')],
66
+ ['isbn', String(info?.isbn ?? '')],
67
+ ['wordCount', String(info?.wordCount ?? '')],
68
+ ['rating', formatRating(info?.newRating)],
69
+ ['ratingCount', String(info?.newRatingCount ?? '')],
70
+ ['intro', truncate(info?.intro, 400)],
71
+ ['cover', String(info?.cover ?? '')],
72
+ ];
73
+ for (let i = 0; i < infoPairs.length; i += 1) {
74
+ const [key, value] = infoPairs[i];
75
+ rows.push({ section: 'info', idx: i + 1, key, value, link: '' });
76
+ }
77
+ rows.push({
78
+ section: 'info',
79
+ idx: infoPairs.length + 1,
80
+ key: 'link',
81
+ value: '',
82
+ link: makeDeepLink({ bookId }),
83
+ });
84
+
85
+ // ── chapters section ────────────────────────────────────────────────
86
+ if (chapters) {
87
+ const list = Array.isArray(chapters?.chapters) ? chapters.chapters : [];
88
+ list.forEach((ch, i) => {
89
+ const chapterUid = String(ch?.chapterUid ?? '').trim();
90
+ const level = Number(ch?.level ?? 1);
91
+ const indent = ' '.repeat(Math.max(0, level - 1));
92
+ const title = `${indent}${String(ch?.title ?? '')}`;
93
+ const wordCount = Number(ch?.wordCount ?? 0);
94
+ const paid = Number(ch?.paid ?? 0) === 1;
95
+ const price = Number(ch?.price ?? 0);
96
+ const meta = [`${wordCount}字`];
97
+ if (price > 0) meta.push(paid ? '已购买' : `${price}元`);
98
+ rows.push({
99
+ section: 'chapter',
100
+ idx: Number(ch?.chapterIdx ?? i + 1),
101
+ key: chapterUid,
102
+ value: `${title} (${meta.join(' · ')})`,
103
+ link: chapterUid ? makeDeepLink({ bookId, chapterUid }) : '',
104
+ });
105
+ });
106
+ }
107
+
108
+ // ── progress section ────────────────────────────────────────────────
109
+ if (progress) {
110
+ const p = progress?.book ?? {};
111
+ const pct = Number(p?.progress ?? 0);
112
+ const updateTime = formatDate(p?.updateTime);
113
+ const finishTime = pct === 100 ? formatDate(p?.finishTime) : '';
114
+ const cumulative = formatDuration(p?.recordReadingTime);
115
+ const isStart = Number(p?.isStartReading ?? 0) === 1;
116
+ const progressPairs = [
117
+ ['progress', `${pct}%`],
118
+ ['cumulative', cumulative],
119
+ ['lastReadAt', updateTime],
120
+ ['finishedAt', finishTime],
121
+ ['isStartReading', isStart ? 'true' : 'false'],
122
+ ['currentChapterUid', String(p?.chapterUid ?? '')],
123
+ ];
124
+ for (let i = 0; i < progressPairs.length; i += 1) {
125
+ const [key, value] = progressPairs[i];
126
+ rows.push({ section: 'progress', idx: i + 1, key, value, link: '' });
127
+ }
128
+ }
129
+
130
+ if (rows.length === 0) {
131
+ emptyResult('book', `No data returned for bookId=${bookId}`);
132
+ }
133
+ return rows;
134
+ },
135
+ });