@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
@@ -0,0 +1,171 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import {
3
+ callGateway,
4
+ emptyResult,
5
+ formatDate,
6
+ makeDeepLink,
7
+ parseRange,
8
+ requireBookId,
9
+ requirePositiveInt,
10
+ truncate,
11
+ } from './utils.js';
12
+
13
+ /**
14
+ * Two modes share a single command (matches the ninehills reference shape):
15
+ *
16
+ * - No bookId → `/user/notebooks` overview. One row per book that has notes.
17
+ * Total-note column follows SKILL.md statistical contract:
18
+ * `total = reviewCount + noteCount + bookmarkCount`. The
19
+ * individual fields stay separate so downstream consumers do
20
+ * not have to re-derive them.
21
+ *
22
+ * - With bookId → merge `/book/bookmarklist` (highlights) +
23
+ * `/review/list/mine` (thoughts/reviews) into one feed.
24
+ * Bookmarks (type=0) are filtered server-side already.
25
+ *
26
+ * Pagination of `/user/notebooks` is cursor-based via `lastSort`; this command
27
+ * surfaces `--last-sort` rather than offset/limit to keep the SKILL.md contract
28
+ * visible at the CLI surface.
29
+ */
30
+ cli({
31
+ site: 'weread-official',
32
+ name: 'notes',
33
+ access: 'read',
34
+ description: 'List notebooks overview or merged highlights+thoughts for a book',
35
+ domain: 'weread.qq.com',
36
+ strategy: Strategy.PUBLIC,
37
+ browser: false,
38
+ args: [
39
+ { name: 'bookId', positional: true, help: 'Limit to one book; omit for full notebook overview' },
40
+ { name: 'count', type: 'int', default: 20, help: 'Page size for the notebooks overview (1-100)' },
41
+ { name: 'last-sort', type: 'int', help: 'Cursor: pass previous page sort value to fetch the next page (/user/notebooks)' },
42
+ ],
43
+ columns: ['kind', 'bookId', 'title', 'author', 'chapter', 'reviewCount', 'noteCount', 'bookmarkCount', 'totalNotes', 'progress', 'finished', 'sort', 'range', 'text', 'thought', 'star', 'createTime', 'link'],
44
+ func: async (args) => {
45
+ const rawBookId = args.bookId !== undefined && args.bookId !== null && String(args.bookId).trim() !== ''
46
+ ? requireBookId(args.bookId)
47
+ : null;
48
+
49
+ if (!rawBookId) {
50
+ return listNotebooks(args);
51
+ }
52
+ return listBookNotes(rawBookId);
53
+ },
54
+ });
55
+
56
+ async function listNotebooks(args) {
57
+ const count = requirePositiveInt(args.count, 'count', { defaultValue: 20, max: 100 });
58
+ const params = { count };
59
+ if (args['last-sort'] !== undefined && args['last-sort'] !== null && args['last-sort'] !== '') {
60
+ params.lastSort = requirePositiveInt(args['last-sort'], 'last-sort');
61
+ }
62
+ const payload = await callGateway('/user/notebooks', params);
63
+ const books = Array.isArray(payload?.books) ? payload.books : [];
64
+ if (books.length === 0) {
65
+ emptyResult('notes', 'No notebooks found.');
66
+ }
67
+ return books.map((entry) => {
68
+ const book = entry?.book ?? {};
69
+ const bookId = String(entry?.bookId ?? book?.bookId ?? '').trim();
70
+ const reviewCount = Number(entry?.reviewCount ?? 0);
71
+ const noteCount = Number(entry?.noteCount ?? 0);
72
+ const bookmarkCount = Number(entry?.bookmarkCount ?? 0);
73
+ return {
74
+ kind: 'notebook',
75
+ bookId,
76
+ title: String(book?.title ?? ''),
77
+ author: String(book?.author ?? ''),
78
+ chapter: '',
79
+ reviewCount,
80
+ noteCount,
81
+ bookmarkCount,
82
+ totalNotes: reviewCount + noteCount + bookmarkCount,
83
+ progress: String(entry?.readingProgress ?? ''),
84
+ finished: Number(entry?.markedStatus ?? 0) === 1,
85
+ sort: Number(entry?.sort ?? 0),
86
+ range: '',
87
+ text: '',
88
+ thought: '',
89
+ star: '',
90
+ createTime: '',
91
+ link: bookId ? makeDeepLink({ bookId }) : '',
92
+ };
93
+ });
94
+ }
95
+
96
+ async function listBookNotes(bookId) {
97
+ const [bookmarksResp, reviewsResp] = await Promise.all([
98
+ callGateway('/book/bookmarklist', { bookId }),
99
+ callGateway('/review/list/mine', { bookid: bookId }),
100
+ ]);
101
+
102
+ const chapterIndex = new Map();
103
+ const chapterList = Array.isArray(bookmarksResp?.chapters) ? bookmarksResp.chapters : [];
104
+ for (const ch of chapterList) {
105
+ const uid = String(ch?.chapterUid ?? '').trim();
106
+ if (!uid) continue;
107
+ chapterIndex.set(uid, String(ch?.title ?? ''));
108
+ }
109
+
110
+ const rows = [];
111
+
112
+ const bookmarks = Array.isArray(bookmarksResp?.updated) ? bookmarksResp.updated : [];
113
+ for (const bm of bookmarks) {
114
+ const chapterUid = String(bm?.chapterUid ?? '').trim();
115
+ const { rangeStart, rangeEnd } = parseRange(bm?.range);
116
+ rows.push({
117
+ kind: 'highlight',
118
+ bookId,
119
+ title: '',
120
+ author: '',
121
+ chapter: chapterIndex.get(chapterUid) ?? '',
122
+ reviewCount: 0,
123
+ noteCount: 0,
124
+ bookmarkCount: 0,
125
+ totalNotes: 0,
126
+ progress: '',
127
+ finished: false,
128
+ sort: 0,
129
+ range: String(bm?.range ?? ''),
130
+ text: truncate(bm?.markText, 400),
131
+ thought: '',
132
+ star: '',
133
+ createTime: formatDate(bm?.createTime),
134
+ link: rangeStart && rangeEnd && chapterUid
135
+ ? makeDeepLink({ bookId, chapterUid, rangeStart, rangeEnd })
136
+ : (chapterUid ? makeDeepLink({ bookId, chapterUid }) : makeDeepLink({ bookId })),
137
+ });
138
+ }
139
+
140
+ const reviews = Array.isArray(reviewsResp?.reviews) ? reviewsResp.reviews : [];
141
+ for (const wrapper of reviews) {
142
+ const rv = wrapper?.review ?? {};
143
+ const chapterUid = String(rv?.chapterUid ?? '').trim();
144
+ const star = Number(rv?.star ?? -1);
145
+ rows.push({
146
+ kind: 'thought',
147
+ bookId,
148
+ title: '',
149
+ author: '',
150
+ chapter: String(rv?.chapterName ?? '') || (chapterIndex.get(chapterUid) ?? ''),
151
+ reviewCount: 0,
152
+ noteCount: 0,
153
+ bookmarkCount: 0,
154
+ totalNotes: 0,
155
+ progress: '',
156
+ finished: Number(rv?.isFinish ?? 0) === 1,
157
+ sort: 0,
158
+ range: String(rv?.range ?? ''),
159
+ text: '',
160
+ thought: truncate(rv?.content, 400),
161
+ star: star >= 0 ? String(star) : '',
162
+ createTime: formatDate(rv?.createTime),
163
+ link: chapterUid ? makeDeepLink({ bookId, chapterUid }) : makeDeepLink({ bookId }),
164
+ });
165
+ }
166
+
167
+ if (rows.length === 0) {
168
+ emptyResult('notes', `No highlights or thoughts saved for bookId=${bookId}.`);
169
+ }
170
+ return rows;
171
+ }
@@ -0,0 +1,158 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import {
3
+ callGateway,
4
+ emptyResult,
5
+ formatDate,
6
+ formatDuration,
7
+ requireChoice,
8
+ requirePositiveInt,
9
+ } from './utils.js';
10
+
11
+ /**
12
+ * `/readdata/detail` — reading statistics summary.
13
+ * Per SKILL.md every duration field is in SECONDS — `totalReadTime`,
14
+ * `dayAverageReadTime`, `readLongest[].readTime`, `preferTime`, etc.
15
+ * `preferAuthor[].readTime` is the one exception (already formatted by server
16
+ * as "X小时Y分钟"); we surface it verbatim.
17
+ *
18
+ * Output uses a `section` column so callers can filter:
19
+ * - summary — single overview row
20
+ * - longest — books / albums sorted by read time
21
+ * - readStat — counts (读过 / 读完 / 阅读 / 笔记)
22
+ * - preferCategory / preferAuthor / preferPublisher
23
+ * - preferTime — 24h time distribution (starts at 6am per SKILL.md)
24
+ */
25
+ const MODE_CHOICES = ['weekly', 'monthly', 'annually', 'overall'];
26
+
27
+ cli({
28
+ site: 'weread-official',
29
+ name: 'readdata',
30
+ access: 'read',
31
+ description: 'Reading statistics: time, streak, preferences, top books',
32
+ domain: 'weread.qq.com',
33
+ strategy: Strategy.PUBLIC,
34
+ browser: false,
35
+ args: [
36
+ { name: 'mode', default: 'monthly', choices: MODE_CHOICES, help: 'Stat window: weekly / monthly / annually / overall' },
37
+ { name: 'base-time', type: 'int', help: 'Optional Unix timestamp inside the target period; default is current period' },
38
+ ],
39
+ columns: ['section', 'idx', 'key', 'value', 'detail'],
40
+ func: async (args) => {
41
+ const mode = requireChoice(args.mode, MODE_CHOICES, 'mode', 'monthly');
42
+ const params = { mode };
43
+ if (args['base-time'] !== undefined && args['base-time'] !== null && args['base-time'] !== '') {
44
+ params.baseTime = requirePositiveInt(args['base-time'], 'base-time');
45
+ }
46
+ const payload = await callGateway('/readdata/detail', params);
47
+
48
+ const rows = [];
49
+ const totalReadTime = Number(payload?.totalReadTime ?? 0);
50
+ const dayAverage = Number(payload?.dayAverageReadTime ?? 0);
51
+ const readDays = Number(payload?.readDays ?? 0);
52
+ const compareRaw = payload?.compare;
53
+ const compare = Number.isFinite(Number(compareRaw)) ? Number(compareRaw) : null;
54
+
55
+ const summary = [
56
+ ['mode', mode, ''],
57
+ ['baseTime', formatDate(payload?.baseTime), String(payload?.baseTime ?? '')],
58
+ ['readDays', String(readDays), ''],
59
+ ['totalReadTime', formatDuration(totalReadTime), `${totalReadTime}s`],
60
+ ['dayAverageReadTime', formatDuration(dayAverage), `${dayAverage}s`],
61
+ ['compareToPrev', compare === null ? '' : `${(compare * 100).toFixed(1)}%`, ''],
62
+ ['readRate', payload?.readRate !== undefined ? `${Number(payload.readRate).toFixed(1)}%` : '', ''],
63
+ ['preferTimeWord', String(payload?.preferTimeWord ?? ''), ''],
64
+ ['preferCategoryWord', String(payload?.preferCategoryWord ?? ''), ''],
65
+ ];
66
+ for (let i = 0; i < summary.length; i += 1) {
67
+ const [key, value, detail] = summary[i];
68
+ rows.push({ section: 'summary', idx: i + 1, key, value, detail });
69
+ }
70
+
71
+ const longest = Array.isArray(payload?.readLongest) ? payload.readLongest : [];
72
+ longest.forEach((entry, i) => {
73
+ const book = entry?.book ?? {};
74
+ const albumInfo = entry?.albumInfo ?? null;
75
+ const title = albumInfo ? String(albumInfo?.name ?? '') : String(book?.title ?? '');
76
+ const author = albumInfo ? String(albumInfo?.authorName ?? '') : String(book?.author ?? '');
77
+ const tags = Array.isArray(entry?.tags) ? entry.tags.join(',') : '';
78
+ const readTime = Number(entry?.readTime ?? 0);
79
+ rows.push({
80
+ section: 'longest',
81
+ idx: i + 1,
82
+ key: title,
83
+ value: formatDuration(readTime),
84
+ detail: `${author}${tags ? ` [${tags}]` : ''}`,
85
+ });
86
+ });
87
+
88
+ const readStat = Array.isArray(payload?.readStat) ? payload.readStat : [];
89
+ readStat.forEach((entry, i) => {
90
+ rows.push({
91
+ section: 'readStat',
92
+ idx: i + 1,
93
+ key: String(entry?.stat ?? ''),
94
+ value: String(entry?.counts ?? ''),
95
+ detail: '',
96
+ });
97
+ });
98
+
99
+ const preferCategory = Array.isArray(payload?.preferCategory) ? payload.preferCategory : [];
100
+ preferCategory.forEach((entry, i) => {
101
+ const seconds = Number(entry?.readingTime ?? 0);
102
+ rows.push({
103
+ section: 'preferCategory',
104
+ idx: i + 1,
105
+ key: String(entry?.categoryTitle ?? ''),
106
+ value: formatDuration(seconds),
107
+ detail: `${Number(entry?.readingCount ?? 0)}本`,
108
+ });
109
+ });
110
+
111
+ const preferAuthor = Array.isArray(payload?.preferAuthor) ? payload.preferAuthor : [];
112
+ preferAuthor.forEach((entry, i) => {
113
+ rows.push({
114
+ section: 'preferAuthor',
115
+ idx: i + 1,
116
+ key: String(entry?.name ?? ''),
117
+ // preferAuthor[].readTime is server-formatted ("5小时30分钟"), not seconds.
118
+ value: String(entry?.readTime ?? ''),
119
+ detail: `${Number(entry?.count ?? 0)}本`,
120
+ });
121
+ });
122
+
123
+ const preferPublisher = Array.isArray(payload?.preferPublisher) ? payload.preferPublisher : [];
124
+ preferPublisher.forEach((entry, i) => {
125
+ rows.push({
126
+ section: 'preferPublisher',
127
+ idx: i + 1,
128
+ key: String(entry?.name ?? ''),
129
+ value: `${Number(entry?.count ?? 0)}本`,
130
+ detail: '',
131
+ });
132
+ });
133
+
134
+ const preferTime = Array.isArray(payload?.preferTime) ? payload.preferTime : [];
135
+ if (preferTime.length === 24) {
136
+ // SKILL.md: order starts at 06:00 and wraps to 05:00. Translate to a flat
137
+ // (hour-of-day → duration) view so consumers do not need to know the offset.
138
+ for (let i = 0; i < 24; i += 1) {
139
+ const hour = (6 + i) % 24;
140
+ const seconds = Number(preferTime[i] ?? 0);
141
+ rows.push({
142
+ section: 'preferTime',
143
+ idx: i + 1,
144
+ key: `${String(hour).padStart(2, '0')}:00`,
145
+ value: formatDuration(seconds),
146
+ detail: `${seconds}s`,
147
+ });
148
+ }
149
+ }
150
+
151
+ if (rows.length === 0) {
152
+ emptyResult('readdata', `No reading data for mode=${mode}.`);
153
+ }
154
+ return rows;
155
+ },
156
+ });
157
+
158
+ export { MODE_CHOICES };
@@ -0,0 +1,93 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError } from '@jackwener/opencli/errors';
3
+ import {
4
+ callGateway,
5
+ emptyResult,
6
+ formatDate,
7
+ formatStar,
8
+ makeDeepLink,
9
+ requireBookId,
10
+ requirePositiveInt,
11
+ truncate,
12
+ } from './utils.js';
13
+
14
+ /**
15
+ * `/review/list` (public book reviews). Per SKILL.md the `reviewListType`
16
+ * enum is:
17
+ * 0=all 1=recommend 2=不行(thumbs-down) 3=newest 4=一般(neutral)
18
+ *
19
+ * Pagination is `idx`-based (use `idx` from last row of previous page as the
20
+ * next `maxIdx`), with an optional `synckey` carried forward. Surface these
21
+ * to the user instead of synthesizing an offset scheme so the SKILL contract
22
+ * stays visible.
23
+ */
24
+ const TYPE_ALIASES = Object.freeze({
25
+ all: 0,
26
+ recommend: 1,
27
+ 'thumbs-down': 2,
28
+ newest: 3,
29
+ neutral: 4,
30
+ });
31
+
32
+ cli({
33
+ site: 'weread-official',
34
+ name: 'review',
35
+ access: 'read',
36
+ description: 'Browse public reviews of a WeRead book',
37
+ domain: 'weread.qq.com',
38
+ strategy: Strategy.PUBLIC,
39
+ browser: false,
40
+ args: [
41
+ { name: 'bookId', positional: true, required: true, help: 'WeRead bookId (from `weread-official search`)' },
42
+ { name: 'type', default: 'all', choices: Object.keys(TYPE_ALIASES), help: 'Review filter (all/recommend/thumbs-down/newest/neutral)' },
43
+ { name: 'count', type: 'int', default: 20, help: 'Page size (1-100, default 20)' },
44
+ { name: 'max-idx', type: 'int', default: 0, help: 'Pagination cursor — pass idx from last row of previous page' },
45
+ { name: 'synckey', type: 'int', help: 'Sync cursor returned by previous response' },
46
+ ],
47
+ columns: ['rank', 'idx', 'reviewId', 'star', 'starLabel', 'author', 'isFinish', 'chapter', 'content', 'createTime', 'link'],
48
+ func: async (args) => {
49
+ const bookId = requireBookId(args.bookId);
50
+ const typeKey = String(args.type ?? 'all').trim();
51
+ if (!Object.prototype.hasOwnProperty.call(TYPE_ALIASES, typeKey)) {
52
+ throw new ArgumentError(
53
+ `weread-official: type must be one of: ${Object.keys(TYPE_ALIASES).join(', ')}`,
54
+ );
55
+ }
56
+ const reviewListType = TYPE_ALIASES[typeKey];
57
+ const count = requirePositiveInt(args.count, 'count', { defaultValue: 20, max: 100 });
58
+ const params = { bookId, reviewListType, count, maxIdx: Number(args['max-idx'] ?? 0) };
59
+ if (args.synckey !== undefined && args.synckey !== null && args.synckey !== '') {
60
+ params.synckey = requirePositiveInt(args.synckey, 'synckey');
61
+ }
62
+
63
+ const payload = await callGateway('/review/list', params);
64
+ const reviews = Array.isArray(payload?.reviews) ? payload.reviews : [];
65
+ if (reviews.length === 0) {
66
+ emptyResult('review', `No public reviews for bookId=${bookId} (type=${typeKey}).`);
67
+ }
68
+
69
+ return reviews.map((wrapper, i) => {
70
+ const reviewOuter = wrapper?.review ?? {};
71
+ // `/review/list` nests the actual review under reviewOuter.review
72
+ const rv = reviewOuter?.review ?? {};
73
+ const author = rv?.author ?? {};
74
+ const chapter = String(rv?.chapterName ?? '').trim();
75
+ const reviewId = String(reviewOuter?.reviewId ?? rv?.reviewId ?? '').trim();
76
+ return {
77
+ rank: i + 1,
78
+ idx: Number(wrapper?.idx ?? 0),
79
+ reviewId,
80
+ star: Number(rv?.star ?? 0),
81
+ starLabel: formatStar(rv?.star),
82
+ author: String(author?.name ?? ''),
83
+ isFinish: Number(rv?.isFinish ?? 0) === 1,
84
+ chapter,
85
+ content: truncate(rv?.content, 300),
86
+ createTime: formatDate(rv?.createTime),
87
+ link: makeDeepLink({ bookId }),
88
+ };
89
+ });
90
+ },
91
+ });
92
+
93
+ export { TYPE_ALIASES };
@@ -0,0 +1,106 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError } from '@jackwener/opencli/errors';
3
+ import {
4
+ callGateway,
5
+ emptyResult,
6
+ formatRating,
7
+ makeDeepLink,
8
+ requirePositiveInt,
9
+ requireText,
10
+ truncate,
11
+ } from './utils.js';
12
+
13
+ /**
14
+ * Search scope mapping — mirrors the official SKILL.md "scope 对应关系" table.
15
+ * Keys are user-facing aliases; values are the int the gateway expects.
16
+ */
17
+ export const SEARCH_SCOPES = Object.freeze({
18
+ all: 0,
19
+ ebook: 10,
20
+ webnovel: 16,
21
+ audio: 14,
22
+ author: 6,
23
+ fulltext: 12,
24
+ booklist: 13,
25
+ mp: 2,
26
+ article: 4,
27
+ });
28
+
29
+ const SCOPE_LABEL = Object.freeze({
30
+ 0: '全部',
31
+ 10: '电子书',
32
+ 16: '网文小说',
33
+ 14: '微信听书',
34
+ 6: '作者',
35
+ 12: '全文',
36
+ 13: '书单',
37
+ 2: '公众号',
38
+ 4: '文章',
39
+ });
40
+
41
+ cli({
42
+ site: 'weread-official',
43
+ name: 'search',
44
+ access: 'read',
45
+ description: 'Search WeRead store via the official agent gateway',
46
+ domain: 'weread.qq.com',
47
+ strategy: Strategy.PUBLIC,
48
+ browser: false,
49
+ args: [
50
+ { name: 'keyword', positional: true, required: true, help: 'Search keyword' },
51
+ { name: 'scope', default: 'ebook', choices: Object.keys(SEARCH_SCOPES), help: 'Search type (all/ebook/webnovel/audio/author/fulltext/booklist/mp/article)' },
52
+ { name: 'count', type: 'int', help: 'Page size (gateway default 15 when omitted)' },
53
+ { name: 'max-idx', type: 'int', default: 0, help: 'Pagination offset, use searchIdx of last item from previous page' },
54
+ ],
55
+ columns: ['rank', 'scope', 'bookId', 'title', 'author', 'rating', 'readingCount', 'category', 'searchIdx', 'cover', 'intro', 'link'],
56
+ func: async (args) => {
57
+ const keyword = requireText(args.keyword, 'keyword');
58
+ const scopeKey = String(args.scope ?? 'ebook').trim();
59
+ if (!Object.prototype.hasOwnProperty.call(SEARCH_SCOPES, scopeKey)) {
60
+ throw new ArgumentError(
61
+ `weread-official: scope must be one of: ${Object.keys(SEARCH_SCOPES).join(', ')}`,
62
+ );
63
+ }
64
+ const scope = SEARCH_SCOPES[scopeKey];
65
+ const params = { keyword, scope, maxIdx: args['max-idx'] ?? 0 };
66
+ if (args.count !== undefined && args.count !== null && args.count !== '') {
67
+ params.count = requirePositiveInt(args.count, 'count', { max: 100 });
68
+ }
69
+
70
+ const payload = await callGateway('/store/search', params);
71
+ const groups = Array.isArray(payload?.results) ? payload.results : [];
72
+ if (groups.length === 0) {
73
+ emptyResult('search', `No results for "${keyword}" (scope=${scopeKey}).`);
74
+ }
75
+
76
+ const rows = [];
77
+ let rank = 0;
78
+ for (const group of groups) {
79
+ const groupScope = SCOPE_LABEL[Number(group?.scope)] ?? '';
80
+ const books = Array.isArray(group?.books) ? group.books : [];
81
+ for (const entry of books) {
82
+ rank += 1;
83
+ const info = entry?.bookInfo ?? {};
84
+ const bookId = String(info.bookId ?? '').trim();
85
+ rows.push({
86
+ rank,
87
+ scope: groupScope,
88
+ bookId,
89
+ title: String(info.title ?? ''),
90
+ author: String(info.author ?? ''),
91
+ rating: formatRating(entry?.newRating),
92
+ readingCount: Number(entry?.readingCount ?? 0),
93
+ category: String(info.category ?? ''),
94
+ searchIdx: Number(entry?.searchIdx ?? 0),
95
+ cover: String(info.cover ?? ''),
96
+ intro: truncate(info.intro, 120),
97
+ link: bookId ? makeDeepLink({ bookId }) : '',
98
+ });
99
+ }
100
+ }
101
+ if (rows.length === 0) {
102
+ emptyResult('search', `No books found in results for "${keyword}" (scope=${scopeKey}).`);
103
+ }
104
+ return rows;
105
+ },
106
+ });
@@ -0,0 +1,97 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { callGateway, emptyResult, formatDate, makeDeepLink } from './utils.js';
3
+
4
+ /**
5
+ * `/shelf/sync` returns three independent entry kinds:
6
+ * - `books[]` — ebooks / imported / mp-style book entries
7
+ * - `albums[]` — audiobooks / podcasts (官方 SKILL.md 强调专辑 = 有声书)
8
+ * - `mp` — opaque "文章收藏" (article bookmarks) entry; non-empty
9
+ * = 1 shelf entry but its content is NOT exposed here.
10
+ *
11
+ * Per official skill: shelf count = books.length + albums.length +
12
+ * (mp non-empty ? 1 : 0). This adapter surfaces all three
13
+ * row kinds in a `kind` column so callers can filter.
14
+ */
15
+ cli({
16
+ site: 'weread-official',
17
+ name: 'shelf',
18
+ access: 'read',
19
+ description: 'Sync your WeRead shelf (books + albums + article bookmark entry) via the official gateway',
20
+ domain: 'weread.qq.com',
21
+ strategy: Strategy.PUBLIC,
22
+ browser: false,
23
+ args: [],
24
+ columns: ['kind', 'id', 'title', 'author', 'category', 'secret', 'isTop', 'finished', 'updateTime', 'cover', 'link'],
25
+ func: async () => {
26
+ const payload = await callGateway('/shelf/sync', {});
27
+ const books = Array.isArray(payload?.books) ? payload.books : [];
28
+ const albums = Array.isArray(payload?.albums) ? payload.albums : [];
29
+ // `mp` is an object (or array) marking the "文章收藏" entry. Non-empty = 1 shelf entry.
30
+ const mp = payload?.mp;
31
+ const hasMp = Boolean(
32
+ mp && typeof mp === 'object'
33
+ && (Array.isArray(mp) ? mp.length > 0 : Object.keys(mp).length > 0),
34
+ );
35
+
36
+ const rows = [];
37
+
38
+ for (const b of books) {
39
+ const bookId = String(b?.bookId ?? '').trim();
40
+ rows.push({
41
+ kind: 'book',
42
+ id: bookId,
43
+ title: String(b?.title ?? ''),
44
+ author: String(b?.author ?? ''),
45
+ category: String(b?.category ?? ''),
46
+ secret: Number(b?.secret ?? 0) === 1,
47
+ isTop: Number(b?.isTop ?? 0) === 1,
48
+ finished: Number(b?.finishReading ?? 0) === 1,
49
+ updateTime: formatDate(b?.readUpdateTime ?? b?.updateTime),
50
+ cover: String(b?.cover ?? ''),
51
+ link: bookId ? makeDeepLink({ bookId }) : '',
52
+ });
53
+ }
54
+
55
+ for (const a of albums) {
56
+ const info = a?.albumInfo ?? {};
57
+ const extra = a?.albumInfoExtra ?? {};
58
+ const albumId = String(info?.albumId ?? '').trim();
59
+ rows.push({
60
+ kind: 'album',
61
+ id: albumId,
62
+ title: String(info?.name ?? ''),
63
+ author: String(info?.authorName ?? ''),
64
+ category: '',
65
+ secret: Number(extra?.secret ?? 0) === 1,
66
+ isTop: Number(extra?.isTop ?? 0) === 1,
67
+ finished: Number(info?.finish ?? 0) === 1,
68
+ updateTime: formatDate(extra?.lectureReadUpdateTime ?? info?.updateTime),
69
+ cover: String(info?.cover ?? ''),
70
+ link: '',
71
+ });
72
+ }
73
+
74
+ if (hasMp) {
75
+ // The mp entry is an opaque directory; only the count is meaningful here.
76
+ // Per SKILL.md it's always private, so secret=true matches the official rule.
77
+ rows.push({
78
+ kind: 'mp',
79
+ id: '',
80
+ title: '文章收藏',
81
+ author: '',
82
+ category: '',
83
+ secret: true,
84
+ isTop: false,
85
+ finished: false,
86
+ updateTime: '',
87
+ cover: '',
88
+ link: '',
89
+ });
90
+ }
91
+
92
+ if (rows.length === 0) {
93
+ emptyResult('shelf', 'Your WeRead shelf is empty.');
94
+ }
95
+ return rows;
96
+ },
97
+ });