@jackwener/opencli 1.5.4 → 1.5.6

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 (256) hide show
  1. package/README.md +27 -2
  2. package/README.zh-CN.md +36 -4
  3. package/dist/browser/daemon-client.d.ts +5 -1
  4. package/dist/browser/page.d.ts +6 -0
  5. package/dist/browser/page.js +15 -0
  6. package/dist/cli-manifest.json +1284 -67
  7. package/dist/cli.js +14 -14
  8. package/dist/clis/antigravity/serve.js +2 -2
  9. package/dist/clis/band/bands.d.ts +1 -0
  10. package/dist/clis/band/bands.js +72 -0
  11. package/dist/clis/band/mentions.d.ts +1 -0
  12. package/dist/clis/band/mentions.js +127 -0
  13. package/dist/clis/band/post.d.ts +1 -0
  14. package/dist/clis/band/post.js +175 -0
  15. package/dist/clis/band/posts.d.ts +1 -0
  16. package/dist/clis/band/posts.js +94 -0
  17. package/dist/clis/doubao/detail.d.ts +1 -0
  18. package/dist/clis/doubao/detail.js +33 -0
  19. package/dist/clis/doubao/detail.test.d.ts +1 -0
  20. package/dist/clis/doubao/detail.test.js +42 -0
  21. package/dist/clis/doubao/history.d.ts +1 -0
  22. package/dist/clis/doubao/history.js +28 -0
  23. package/dist/clis/doubao/history.test.d.ts +1 -0
  24. package/dist/clis/doubao/history.test.js +37 -0
  25. package/dist/clis/doubao/meeting-summary.d.ts +1 -0
  26. package/dist/clis/doubao/meeting-summary.js +39 -0
  27. package/dist/clis/doubao/meeting-transcript.d.ts +1 -0
  28. package/dist/clis/doubao/meeting-transcript.js +36 -0
  29. package/dist/clis/doubao/utils.d.ts +27 -0
  30. package/dist/clis/doubao/utils.js +317 -0
  31. package/dist/clis/doubao/utils.test.d.ts +1 -0
  32. package/dist/clis/doubao/utils.test.js +24 -0
  33. package/dist/clis/douyin/_shared/public-api.d.ts +33 -0
  34. package/dist/clis/douyin/_shared/public-api.js +29 -0
  35. package/dist/clis/douyin/user-videos.d.ts +5 -0
  36. package/dist/clis/douyin/user-videos.js +74 -0
  37. package/dist/clis/douyin/user-videos.test.d.ts +1 -0
  38. package/dist/clis/douyin/user-videos.test.js +108 -0
  39. package/dist/clis/ones/common.d.ts +32 -0
  40. package/dist/clis/ones/common.js +144 -0
  41. package/dist/clis/ones/enrich-tasks.d.ts +5 -0
  42. package/dist/clis/ones/enrich-tasks.js +37 -0
  43. package/dist/clis/ones/login.d.ts +1 -0
  44. package/dist/clis/ones/login.js +80 -0
  45. package/dist/clis/ones/logout.d.ts +1 -0
  46. package/dist/clis/ones/logout.js +17 -0
  47. package/dist/clis/ones/me.d.ts +1 -0
  48. package/dist/clis/ones/me.js +30 -0
  49. package/dist/clis/ones/my-tasks.d.ts +1 -0
  50. package/dist/clis/ones/my-tasks.js +120 -0
  51. package/dist/clis/ones/resolve-labels.d.ts +10 -0
  52. package/dist/clis/ones/resolve-labels.js +64 -0
  53. package/dist/clis/ones/task-helpers.d.ts +29 -0
  54. package/dist/clis/ones/task-helpers.js +212 -0
  55. package/dist/clis/ones/task-helpers.test.d.ts +1 -0
  56. package/dist/clis/ones/task-helpers.test.js +12 -0
  57. package/dist/clis/ones/task.d.ts +1 -0
  58. package/dist/clis/ones/task.js +66 -0
  59. package/dist/clis/ones/tasks.d.ts +1 -0
  60. package/dist/clis/ones/tasks.js +79 -0
  61. package/dist/clis/ones/token-info.d.ts +1 -0
  62. package/dist/clis/ones/token-info.js +42 -0
  63. package/dist/clis/ones/worklog.d.ts +11 -0
  64. package/dist/clis/ones/worklog.js +267 -0
  65. package/dist/clis/ones/worklog.test.d.ts +1 -0
  66. package/dist/clis/ones/worklog.test.js +20 -0
  67. package/dist/clis/sinafinance/rolling-news.d.ts +4 -0
  68. package/dist/clis/sinafinance/rolling-news.js +40 -0
  69. package/dist/clis/sinafinance/stock.d.ts +8 -0
  70. package/dist/clis/sinafinance/stock.js +117 -0
  71. package/dist/clis/spotify/spotify.d.ts +1 -0
  72. package/dist/clis/spotify/spotify.js +316 -0
  73. package/dist/clis/spotify/utils.d.ts +21 -0
  74. package/dist/clis/spotify/utils.js +66 -0
  75. package/dist/clis/spotify/utils.test.d.ts +1 -0
  76. package/dist/clis/spotify/utils.test.js +67 -0
  77. package/dist/clis/tieba/commands.test.d.ts +4 -0
  78. package/dist/clis/tieba/commands.test.js +79 -0
  79. package/dist/clis/tieba/hot.d.ts +1 -0
  80. package/dist/clis/tieba/hot.js +48 -0
  81. package/dist/clis/tieba/posts.d.ts +1 -0
  82. package/dist/clis/tieba/posts.js +85 -0
  83. package/dist/clis/tieba/read.d.ts +1 -0
  84. package/dist/clis/tieba/read.js +140 -0
  85. package/dist/clis/tieba/search.d.ts +1 -0
  86. package/dist/clis/tieba/search.js +108 -0
  87. package/dist/clis/tieba/utils.d.ts +101 -0
  88. package/dist/clis/tieba/utils.js +240 -0
  89. package/dist/clis/tieba/utils.test.d.ts +1 -0
  90. package/dist/clis/tieba/utils.test.js +290 -0
  91. package/dist/clis/weread/book.js +100 -13
  92. package/dist/clis/weread/commands.test.js +221 -0
  93. package/dist/clis/weread/private-api-regression.test.d.ts +1 -0
  94. package/dist/{weread-private-api-regression.test.js → clis/weread/private-api-regression.test.js} +92 -30
  95. package/dist/clis/weread/search-regression.test.d.ts +1 -0
  96. package/dist/clis/weread/search-regression.test.js +407 -0
  97. package/dist/clis/weread/search.js +143 -7
  98. package/dist/clis/weread/shelf.js +13 -95
  99. package/dist/clis/weread/utils.d.ts +46 -0
  100. package/dist/clis/weread/utils.js +214 -7
  101. package/dist/clis/weread/utils.test.js +71 -1
  102. package/dist/clis/xiaohongshu/publish.d.ts +1 -1
  103. package/dist/clis/xiaohongshu/publish.js +78 -31
  104. package/dist/clis/xiaohongshu/publish.test.js +66 -1
  105. package/dist/clis/xiaohongshu/user-helpers.d.ts +1 -0
  106. package/dist/clis/xiaohongshu/user-helpers.js +2 -0
  107. package/dist/clis/xiaohongshu/user-helpers.test.js +18 -0
  108. package/dist/clis/xueqiu/comments.d.ts +118 -0
  109. package/dist/clis/xueqiu/comments.js +354 -0
  110. package/dist/clis/xueqiu/comments.test.d.ts +1 -0
  111. package/dist/clis/xueqiu/comments.test.js +696 -0
  112. package/dist/clis/youtube/transcript.js +2 -4
  113. package/dist/clis/youtube/utils.d.ts +9 -0
  114. package/dist/clis/youtube/utils.js +67 -3
  115. package/dist/clis/youtube/utils.test.d.ts +1 -0
  116. package/dist/clis/youtube/utils.test.js +37 -0
  117. package/dist/clis/youtube/video.js +16 -15
  118. package/dist/clis/zsxq/dynamics.d.ts +1 -0
  119. package/dist/clis/zsxq/dynamics.js +47 -0
  120. package/dist/clis/zsxq/groups.d.ts +1 -0
  121. package/dist/clis/zsxq/groups.js +32 -0
  122. package/dist/clis/zsxq/search.d.ts +1 -0
  123. package/dist/clis/zsxq/search.js +43 -0
  124. package/dist/clis/zsxq/search.test.d.ts +1 -0
  125. package/dist/clis/zsxq/search.test.js +24 -0
  126. package/dist/clis/zsxq/topic.d.ts +1 -0
  127. package/dist/clis/zsxq/topic.js +47 -0
  128. package/dist/clis/zsxq/topic.test.d.ts +1 -0
  129. package/dist/clis/zsxq/topic.test.js +29 -0
  130. package/dist/clis/zsxq/topics.d.ts +1 -0
  131. package/dist/clis/zsxq/topics.js +25 -0
  132. package/dist/clis/zsxq/topics.test.d.ts +1 -0
  133. package/dist/clis/zsxq/topics.test.js +24 -0
  134. package/dist/clis/zsxq/utils.d.ts +97 -0
  135. package/dist/clis/zsxq/utils.js +230 -0
  136. package/dist/commanderAdapter.js +27 -4
  137. package/dist/commanderAdapter.test.js +39 -0
  138. package/dist/daemon.js +5 -4
  139. package/dist/errors.d.ts +29 -1
  140. package/dist/errors.js +49 -11
  141. package/dist/external-clis.yaml +17 -0
  142. package/dist/external.js +3 -3
  143. package/dist/main.js +2 -1
  144. package/dist/tui.js +2 -1
  145. package/dist/types.d.ts +5 -0
  146. package/docs/.vitepress/config.mts +3 -0
  147. package/docs/adapters/browser/band.md +63 -0
  148. package/docs/adapters/browser/ones.md +59 -0
  149. package/docs/adapters/browser/sinafinance.md +56 -6
  150. package/docs/adapters/browser/spotify.md +62 -0
  151. package/docs/adapters/browser/tieba.md +45 -0
  152. package/docs/adapters/browser/xueqiu.md +5 -0
  153. package/docs/adapters/browser/zsxq.md +49 -0
  154. package/docs/adapters/index.md +5 -2
  155. package/docs/adapters-doc/ones.md +32 -0
  156. package/extension/dist/background.js +1 -2
  157. package/extension/manifest.json +1 -1
  158. package/extension/package.json +1 -1
  159. package/extension/src/background.ts +17 -1
  160. package/extension/src/cdp.ts +42 -0
  161. package/extension/src/protocol.ts +5 -1
  162. package/package.json +1 -1
  163. package/scripts/postinstall.js +16 -0
  164. package/src/browser/daemon-client.ts +5 -1
  165. package/src/browser/page.ts +16 -0
  166. package/src/cli.ts +14 -14
  167. package/src/clis/antigravity/serve.ts +2 -2
  168. package/src/clis/band/bands.ts +76 -0
  169. package/src/clis/band/mentions.ts +134 -0
  170. package/src/clis/band/post.ts +187 -0
  171. package/src/clis/band/posts.ts +106 -0
  172. package/src/clis/doubao/detail.test.ts +53 -0
  173. package/src/clis/doubao/detail.ts +41 -0
  174. package/src/clis/doubao/history.test.ts +45 -0
  175. package/src/clis/doubao/history.ts +32 -0
  176. package/src/clis/doubao/meeting-summary.ts +53 -0
  177. package/src/clis/doubao/meeting-transcript.ts +48 -0
  178. package/src/clis/doubao/utils.test.ts +45 -0
  179. package/src/clis/doubao/utils.ts +371 -0
  180. package/src/clis/douyin/_shared/public-api.ts +84 -0
  181. package/src/clis/douyin/user-videos.test.ts +122 -0
  182. package/src/clis/douyin/user-videos.ts +101 -0
  183. package/src/clis/ones/common.ts +187 -0
  184. package/src/clis/ones/enrich-tasks.ts +47 -0
  185. package/src/clis/ones/login.ts +103 -0
  186. package/src/clis/ones/logout.ts +19 -0
  187. package/src/clis/ones/me.ts +34 -0
  188. package/src/clis/ones/my-tasks.ts +148 -0
  189. package/src/clis/ones/resolve-labels.ts +80 -0
  190. package/src/clis/ones/task-helpers.test.ts +14 -0
  191. package/src/clis/ones/task-helpers.ts +214 -0
  192. package/src/clis/ones/task.ts +79 -0
  193. package/src/clis/ones/tasks.ts +92 -0
  194. package/src/clis/ones/token-info.ts +46 -0
  195. package/src/clis/ones/worklog.test.ts +24 -0
  196. package/src/clis/ones/worklog.ts +306 -0
  197. package/src/clis/sinafinance/rolling-news.ts +42 -0
  198. package/src/clis/sinafinance/stock.ts +127 -0
  199. package/src/clis/spotify/spotify.ts +328 -0
  200. package/src/clis/spotify/utils.test.ts +87 -0
  201. package/src/clis/spotify/utils.ts +92 -0
  202. package/src/clis/tieba/commands.test.ts +86 -0
  203. package/src/clis/tieba/hot.ts +52 -0
  204. package/src/clis/tieba/posts.ts +108 -0
  205. package/src/clis/tieba/read.ts +158 -0
  206. package/src/clis/tieba/search.ts +119 -0
  207. package/src/clis/tieba/utils.test.ts +322 -0
  208. package/src/clis/tieba/utils.ts +348 -0
  209. package/src/clis/weread/book.ts +116 -13
  210. package/src/clis/weread/commands.test.ts +249 -0
  211. package/src/{weread-private-api-regression.test.ts → clis/weread/private-api-regression.test.ts} +108 -30
  212. package/src/clis/weread/search-regression.test.ts +440 -0
  213. package/src/clis/weread/search.ts +189 -9
  214. package/src/clis/weread/shelf.ts +20 -122
  215. package/src/clis/weread/utils.test.ts +81 -1
  216. package/src/clis/weread/utils.ts +264 -7
  217. package/src/clis/xiaohongshu/publish.test.ts +79 -1
  218. package/src/clis/xiaohongshu/publish.ts +84 -30
  219. package/src/clis/xiaohongshu/user-helpers.test.ts +23 -0
  220. package/src/clis/xiaohongshu/user-helpers.ts +4 -0
  221. package/src/clis/xueqiu/comments.test.ts +823 -0
  222. package/src/clis/xueqiu/comments.ts +461 -0
  223. package/src/clis/youtube/transcript.ts +2 -4
  224. package/src/clis/youtube/utils.test.ts +43 -0
  225. package/src/clis/youtube/utils.ts +69 -0
  226. package/src/clis/youtube/video.ts +16 -15
  227. package/src/clis/zsxq/dynamics.ts +60 -0
  228. package/src/clis/zsxq/groups.ts +41 -0
  229. package/src/clis/zsxq/search.test.ts +29 -0
  230. package/src/clis/zsxq/search.ts +54 -0
  231. package/src/clis/zsxq/topic.test.ts +34 -0
  232. package/src/clis/zsxq/topic.ts +68 -0
  233. package/src/clis/zsxq/topics.test.ts +29 -0
  234. package/src/clis/zsxq/topics.ts +36 -0
  235. package/src/clis/zsxq/utils.ts +351 -0
  236. package/src/commanderAdapter.test.ts +47 -0
  237. package/src/commanderAdapter.ts +26 -3
  238. package/src/daemon.ts +5 -4
  239. package/src/errors.ts +71 -10
  240. package/src/external-clis.yaml +17 -0
  241. package/src/external.ts +3 -3
  242. package/src/main.ts +2 -1
  243. package/src/tui.ts +2 -1
  244. package/src/types.ts +5 -0
  245. package/tests/e2e/band-auth.test.ts +20 -0
  246. package/tests/e2e/browser-auth-helpers.ts +18 -0
  247. package/tests/e2e/browser-auth.test.ts +35 -47
  248. package/tests/e2e/browser-public.test.ts +288 -0
  249. package/tests/e2e/management.test.ts +1 -1
  250. package/tests/e2e/plugin-management.test.ts +1 -1
  251. package/vitest.config.ts +1 -0
  252. package/SKILL.md +0 -879
  253. package/dist/weread-private-api-regression.test.d.ts +0 -1
  254. package/dist/weread-search-regression.test.d.ts +0 -1
  255. package/dist/weread-search-regression.test.js +0 -39
  256. package/src/weread-search-regression.test.ts +0 -44
@@ -0,0 +1,290 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MAX_TIEBA_LIMIT, buildTiebaPostCardsFromPagePc, buildTiebaPostItems, buildTiebaSearchItems, buildTiebaReadItems, normalizeTiebaLimit, signTiebaPcParams, } from './utils.js';
3
+ describe('normalizeTiebaLimit', () => {
4
+ it('caps list commands at the declared tieba maximum', () => {
5
+ expect(MAX_TIEBA_LIMIT).toBe(20);
6
+ expect(normalizeTiebaLimit(undefined)).toBe(20);
7
+ expect(normalizeTiebaLimit(25)).toBe(20);
8
+ expect(normalizeTiebaLimit(7)).toBe(7);
9
+ });
10
+ });
11
+ describe('signTiebaPcParams', () => {
12
+ it('matches Tieba PC forum-list signing for stable page_pc requests', () => {
13
+ expect(signTiebaPcParams({
14
+ kw: encodeURIComponent('李毅'),
15
+ pn: '1',
16
+ sort_type: '-1',
17
+ is_newfrs: '1',
18
+ is_newfeed: '1',
19
+ rn: '30',
20
+ rn_need: '20',
21
+ tbs: '',
22
+ subapp_type: 'pc',
23
+ _client_type: '20',
24
+ })).toBe('466f2e091dd4ed17c6661a842b5ec342');
25
+ });
26
+ });
27
+ describe('buildTiebaPostCardsFromPagePc', () => {
28
+ it('extracts thread cards from signed page_pc feed payloads', () => {
29
+ const cards = buildTiebaPostCardsFromPagePc([
30
+ {
31
+ layout: 'feed',
32
+ feed: {
33
+ schema: 'tiebaapp://router/portal?params=%7B%22pageParams%22%3A%7B%22tid%22%3A10596901456%7D%7D',
34
+ log_param: [
35
+ { key: 'tid', value: '10596901456' },
36
+ ],
37
+ business_info_map: {
38
+ thread_id: '10596901456',
39
+ title: '崇拜希特勒的人都是日本的汉奸走狗',
40
+ },
41
+ components: [
42
+ {
43
+ component: 'feed_head',
44
+ feed_head: {
45
+ extra_data: [
46
+ {
47
+ business_info_map: { time_prefix: '回复于' },
48
+ text: { text: '1774343231' },
49
+ },
50
+ ],
51
+ main_data: [
52
+ {
53
+ text: { text: '上帝的子民º♬' },
54
+ },
55
+ ],
56
+ },
57
+ },
58
+ {
59
+ component: 'feed_title',
60
+ feed_title: {
61
+ data: [{ text_info: { text: '崇拜希特勒的人都是日本的汉奸走狗' } }],
62
+ },
63
+ },
64
+ {
65
+ component: 'feed_social',
66
+ feed_social: {
67
+ comment_num: 12,
68
+ },
69
+ },
70
+ ],
71
+ },
72
+ },
73
+ ]);
74
+ expect(cards).toEqual([
75
+ {
76
+ title: '崇拜希特勒的人都是日本的汉奸走狗',
77
+ author: '上帝的子民º♬',
78
+ descInfo: '回复于2026-03-24 17:07',
79
+ commentCount: 12,
80
+ actionTexts: [],
81
+ threadId: '10596901456',
82
+ url: 'https://tieba.baidu.com/p/10596901456',
83
+ },
84
+ ]);
85
+ });
86
+ });
87
+ describe('buildTiebaPostItems', () => {
88
+ it('builds stable thread ids and urls from card props without page hops', () => {
89
+ const items = buildTiebaPostItems([
90
+ {
91
+ title: '我来说个事',
92
+ author: '暴躁的小伙子',
93
+ descInfo: '回复于2分钟前',
94
+ actionTexts: ['分享', '评论 5', '点赞 2'],
95
+ threadId: '10590564788',
96
+ },
97
+ ], 5);
98
+ expect(items).toEqual([
99
+ {
100
+ rank: 1,
101
+ title: '我来说个事',
102
+ author: '暴躁的小伙子',
103
+ replies: 5,
104
+ last_reply: '2分钟前',
105
+ id: '10590564788',
106
+ url: 'https://tieba.baidu.com/p/10590564788',
107
+ },
108
+ ]);
109
+ });
110
+ it('honors the public 20-item limit contract', () => {
111
+ const raw = Array.from({ length: 25 }, (_, index) => ({
112
+ title: `帖子 ${index + 1}`,
113
+ author: `作者 ${index + 1}`,
114
+ descInfo: '回复于刚刚',
115
+ actionTexts: ['分享', `评论 ${index + 1}`],
116
+ threadId: String(1000 + index),
117
+ }));
118
+ const items = buildTiebaPostItems(raw, 25);
119
+ expect(items).toHaveLength(20);
120
+ expect(items[19]).toMatchObject({
121
+ rank: 20,
122
+ id: '1019',
123
+ url: 'https://tieba.baidu.com/p/1019',
124
+ });
125
+ });
126
+ it('parses Chinese count units and keeps date-time last-reply text intact', () => {
127
+ const items = buildTiebaPostItems([
128
+ {
129
+ title: '复杂格式帖子',
130
+ author: '作者',
131
+ descInfo: '回复于03-29 11:35',
132
+ actionTexts: ['分享', '评论 1.2万'],
133
+ url: 'https://tieba.baidu.com/p/123456',
134
+ },
135
+ ], 5);
136
+ expect(items[0]).toMatchObject({
137
+ replies: 12000,
138
+ last_reply: '03-29 11:35',
139
+ id: '123456',
140
+ url: 'https://tieba.baidu.com/p/123456',
141
+ });
142
+ });
143
+ });
144
+ describe('buildTiebaSearchItems', () => {
145
+ it('keeps up to 20 search results when the page provides more than 10 cards', () => {
146
+ const raw = Array.from({ length: 25 }, (_, index) => ({
147
+ title: `结果 ${index + 1}`,
148
+ forum: '编程吧',
149
+ author: `作者 ${index + 1}`,
150
+ time: '2026-03-29',
151
+ snippet: `摘要 ${index + 1}`,
152
+ id: String(2000 + index),
153
+ url: `https://tieba.baidu.com/p/${2000 + index}`,
154
+ }));
155
+ const items = buildTiebaSearchItems(raw, 25);
156
+ expect(items).toHaveLength(20);
157
+ expect(items[19]).toMatchObject({
158
+ rank: 20,
159
+ id: '2019',
160
+ url: 'https://tieba.baidu.com/p/2019',
161
+ });
162
+ });
163
+ it('fills missing search ids from stable thread urls', () => {
164
+ const items = buildTiebaSearchItems([
165
+ {
166
+ title: '搜索结果',
167
+ forum: '编程吧',
168
+ author: '作者',
169
+ time: '2026-03-29 11:35',
170
+ snippet: '摘要',
171
+ id: '',
172
+ url: 'https://tieba.baidu.com/p/654321',
173
+ },
174
+ ], 5);
175
+ expect(items[0]).toMatchObject({
176
+ id: '654321',
177
+ url: 'https://tieba.baidu.com/p/654321',
178
+ });
179
+ });
180
+ });
181
+ describe('buildTiebaReadItems', () => {
182
+ it('prefers visible main-post fields and still keeps floor 1 for media-only threads', () => {
183
+ const items = buildTiebaReadItems({
184
+ mainPost: {
185
+ title: '刚开始读博士的人据说都这样',
186
+ author: '湖水之岸',
187
+ contentText: '',
188
+ structuredText: '',
189
+ visibleTime: '03-24',
190
+ structuredTime: 1774343231,
191
+ hasMedia: true,
192
+ },
193
+ replies: [],
194
+ }, { limit: 5, includeMainPost: true });
195
+ expect(items).toEqual([
196
+ {
197
+ floor: 1,
198
+ author: '湖水之岸',
199
+ content: '刚开始读博士的人据说都这样 [media]',
200
+ time: '03-24',
201
+ },
202
+ ]);
203
+ });
204
+ it('falls back to structured main-post data when visible text is missing', () => {
205
+ const items = buildTiebaReadItems({
206
+ mainPost: {
207
+ title: '标题',
208
+ author: '',
209
+ fallbackAuthor: '结构化作者',
210
+ contentText: '',
211
+ structuredText: '结构化正文',
212
+ visibleTime: '',
213
+ structuredTime: 1774343231,
214
+ hasMedia: false,
215
+ },
216
+ replies: [
217
+ { floor: 2, author: '回复者', content: '二楼内容', time: '第2楼 2026-03-25 12:34 广东' },
218
+ ],
219
+ }, { limit: 5, includeMainPost: true });
220
+ expect(items[0]).toMatchObject({
221
+ floor: 1,
222
+ author: '结构化作者',
223
+ content: '标题 结构化正文',
224
+ time: '2026-03-24 17:07',
225
+ });
226
+ expect(items[1]).toMatchObject({
227
+ floor: 2,
228
+ author: '回复者',
229
+ content: '二楼内容',
230
+ time: '2026-03-25 12:34',
231
+ });
232
+ });
233
+ it('strips trailing location metadata from reply times', () => {
234
+ const items = buildTiebaReadItems({
235
+ mainPost: {
236
+ title: '主楼',
237
+ author: '楼主',
238
+ contentText: '正文',
239
+ visibleTime: '03-24',
240
+ },
241
+ replies: [
242
+ { floor: 2, author: '二楼', content: '二楼内容', time: '第2楼 3小时前 福建' },
243
+ { floor: 3, author: '三楼', content: '三楼内容', time: '第3楼 刚刚 江苏' },
244
+ ],
245
+ }, { limit: 5, includeMainPost: false });
246
+ expect(items).toEqual([
247
+ {
248
+ floor: 2,
249
+ author: '二楼',
250
+ content: '二楼内容',
251
+ time: '3小时前',
252
+ },
253
+ {
254
+ floor: 3,
255
+ author: '三楼',
256
+ content: '三楼内容',
257
+ time: '刚刚',
258
+ },
259
+ ]);
260
+ });
261
+ it('counts limit as replies and skips main post on later pages', () => {
262
+ const items = buildTiebaReadItems({
263
+ mainPost: {
264
+ title: '主楼',
265
+ author: '楼主',
266
+ contentText: '正文',
267
+ visibleTime: '03-24',
268
+ },
269
+ replies: [
270
+ { floor: 2, author: '二楼', content: '二楼内容', time: '第2楼 03-25' },
271
+ { floor: 3, author: '三楼', content: '三楼内容', time: '第3楼 03-26' },
272
+ { floor: 4, author: '四楼', content: '四楼内容', time: '第4楼 03-27' },
273
+ ],
274
+ }, { limit: 2, includeMainPost: true });
275
+ expect(items).toHaveLength(3);
276
+ expect(items.map((item) => item.floor)).toEqual([1, 2, 3]);
277
+ const page2 = buildTiebaReadItems({
278
+ mainPost: {
279
+ title: '主楼',
280
+ author: '楼主',
281
+ contentText: '正文',
282
+ visibleTime: '03-24',
283
+ },
284
+ replies: [
285
+ { floor: 26, author: '二十六楼', content: '二十六楼内容', time: '第26楼 03-29' },
286
+ ],
287
+ }, { limit: 2, includeMainPost: false });
288
+ expect(page2.map((item) => item.floor)).toEqual([26]);
289
+ });
290
+ });
@@ -1,5 +1,68 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
- import { fetchPrivateApi } from './utils.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { fetchPrivateApi, resolveShelfReaderUrl } from './utils.js';
4
+ /**
5
+ * Read visible book metadata from the web reader cover/flyleaf page.
6
+ * This path is used as a fallback when the private API session has expired.
7
+ */
8
+ async function loadReaderFallbackResult(page, readerUrl) {
9
+ await page.goto(readerUrl);
10
+ await page.wait({ selector: '.horizontalReaderCoverPage_content_bookTitle, .wr_flyleaf_page_bookInfo_bookTitle', timeout: 10 });
11
+ const result = await page.evaluate(`
12
+ (() => {
13
+ const text = (node) => node?.textContent?.trim() || '';
14
+ const bodyText = document.body?.innerText?.replace(/\\s+/g, ' ').trim() || '';
15
+ const titleSelector = '.horizontalReaderCoverPage_content_bookTitle, .wr_flyleaf_page_bookInfo_bookTitle';
16
+ const authorSelector = '.horizontalReaderCoverPage_content_author, .wr_flyleaf_page_bookInfo_author';
17
+ const extractRating = () => {
18
+ const match = bodyText.match(/微信读书推荐值\\s*([0-9.]+%)/);
19
+ return match ? match[1] : '';
20
+ };
21
+ const extractPublisher = () => {
22
+ const direct = text(document.querySelector('.introDialog_content_pub_line'));
23
+ return direct.startsWith('出版社') ? direct.replace(/^出版社\\s*/, '').trim() : '';
24
+ };
25
+ const extractIntro = () => {
26
+ const selectors = [
27
+ '.horizontalReaderCoverPage_content_bookInfo_intro',
28
+ '.wr_flyleaf_page_bookIntro_content',
29
+ '.introDialog_content_intro_para',
30
+ ];
31
+ for (const selector of selectors) {
32
+ const value = text(document.querySelector(selector));
33
+ if (value) return value;
34
+ }
35
+ return '';
36
+ };
37
+
38
+ const categorySource = Array.from(document.scripts)
39
+ .map((script) => script.textContent || '')
40
+ .find((scriptText) => scriptText.includes('"category"')) || '';
41
+ const categoryMatch = categorySource.match(/"category"\\s*:\\s*"([^"]+)"/);
42
+ const title = text(document.querySelector(titleSelector));
43
+ const author = text(document.querySelector(authorSelector));
44
+
45
+ return {
46
+ title,
47
+ author,
48
+ publisher: extractPublisher(),
49
+ intro: extractIntro(),
50
+ category: categoryMatch ? categoryMatch[1].trim() : '',
51
+ rating: extractRating(),
52
+ metadataReady: Boolean(title || author),
53
+ };
54
+ })()
55
+ `);
56
+ return {
57
+ title: String(result?.title || '').trim(),
58
+ author: String(result?.author || '').trim(),
59
+ publisher: String(result?.publisher || '').trim(),
60
+ intro: String(result?.intro || '').trim(),
61
+ category: String(result?.category || '').trim(),
62
+ rating: String(result?.rating || '').trim(),
63
+ metadataReady: result?.metadataReady === true,
64
+ };
65
+ }
3
66
  cli({
4
67
  site: 'weread',
5
68
  name: 'book',
@@ -7,20 +70,44 @@ cli({
7
70
  domain: 'weread.qq.com',
8
71
  strategy: Strategy.COOKIE,
9
72
  args: [
10
- { name: 'book-id', positional: true, required: true, help: 'Book ID (numeric, from search or shelf results)' },
73
+ { name: 'book-id', positional: true, required: true, help: 'Book ID from search or shelf results' },
11
74
  ],
12
75
  columns: ['title', 'author', 'publisher', 'intro', 'category', 'rating'],
13
76
  func: async (page, args) => {
14
- const data = await fetchPrivateApi(page, '/book/info', { bookId: args['book-id'] });
15
- // newRating is 0-1000 scale per community docs; needs runtime verification
16
- const rating = data.newRating ? `${(data.newRating / 10).toFixed(1)}%` : '-';
17
- return [{
18
- title: data.title ?? '',
19
- author: data.author ?? '',
20
- publisher: data.publisher ?? '',
21
- intro: data.intro ?? '',
22
- category: data.category ?? '',
23
- rating,
24
- }];
77
+ const bookId = String(args['book-id'] || '').trim();
78
+ try {
79
+ const data = await fetchPrivateApi(page, '/book/info', { bookId });
80
+ // newRating is 0-1000 scale per community docs; needs runtime verification
81
+ const rating = data.newRating ? `${(data.newRating / 10).toFixed(1)}%` : '-';
82
+ return [{
83
+ title: data.title ?? '',
84
+ author: data.author ?? '',
85
+ publisher: data.publisher ?? '',
86
+ intro: data.intro ?? '',
87
+ category: data.category ?? '',
88
+ rating,
89
+ }];
90
+ }
91
+ catch (error) {
92
+ if (!(error instanceof CliError) || error.code !== 'AUTH_REQUIRED') {
93
+ throw error;
94
+ }
95
+ const readerUrl = await resolveShelfReaderUrl(page, bookId);
96
+ if (!readerUrl) {
97
+ throw error;
98
+ }
99
+ const data = await loadReaderFallbackResult(page, readerUrl);
100
+ if (!data.metadataReady || !data.title) {
101
+ throw error;
102
+ }
103
+ return [{
104
+ title: data.title,
105
+ author: data.author,
106
+ publisher: data.publisher,
107
+ intro: data.intro,
108
+ category: data.category,
109
+ rating: data.rating,
110
+ }];
111
+ }
25
112
  },
26
113
  });
@@ -1,4 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { CliError } from '../../errors.js';
2
3
  const { mockFetchPrivateApi } = vi.hoisted(() => ({
3
4
  mockFetchPrivateApi: vi.fn(),
4
5
  }));
@@ -25,6 +26,226 @@ describe('weread book-id positional args', () => {
25
26
  await book.func({}, { 'book-id': '12345' });
26
27
  expect(mockFetchPrivateApi).toHaveBeenCalledWith({}, '/book/info', { bookId: '12345' });
27
28
  });
29
+ it('falls back to the shelf reader page when private API auth has expired', async () => {
30
+ mockFetchPrivateApi.mockRejectedValue(new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first'));
31
+ const page = {
32
+ goto: vi.fn().mockResolvedValue(undefined),
33
+ evaluate: vi.fn()
34
+ .mockResolvedValueOnce({
35
+ cacheFound: true,
36
+ rawBooks: [
37
+ { bookId: 'MP_WXS_3634777637', title: '文明、现代化、价值投资与中国', author: '李录' },
38
+ ],
39
+ shelfIndexes: [
40
+ { bookId: 'MP_WXS_3634777637', idx: 0, role: 'book' },
41
+ ],
42
+ })
43
+ .mockResolvedValueOnce(['https://weread.qq.com/web/reader/6f5323f071bd7f7b6f521e8'])
44
+ .mockResolvedValueOnce({
45
+ title: '文明、现代化、价值投资与中国',
46
+ author: '李录',
47
+ publisher: '中信出版集团',
48
+ intro: '对中国未来几十年的预测。',
49
+ category: '',
50
+ rating: '84.1%',
51
+ metadataReady: true,
52
+ }),
53
+ getCookies: vi.fn().mockResolvedValue([
54
+ { name: 'wr_vid', value: '70486028', domain: '.weread.qq.com' },
55
+ ]),
56
+ wait: vi.fn().mockResolvedValue(undefined),
57
+ };
58
+ const result = await book.func(page, { 'book-id': 'MP_WXS_3634777637' });
59
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://weread.qq.com/web/shelf');
60
+ expect(page.goto).toHaveBeenNthCalledWith(2, 'https://weread.qq.com/web/reader/6f5323f071bd7f7b6f521e8');
61
+ expect(page.evaluate).toHaveBeenCalledTimes(3);
62
+ expect(result).toEqual([
63
+ {
64
+ title: '文明、现代化、价值投资与中国',
65
+ author: '李录',
66
+ publisher: '中信出版集团',
67
+ intro: '对中国未来几十年的预测。',
68
+ category: '',
69
+ rating: '84.1%',
70
+ },
71
+ ]);
72
+ });
73
+ it('keeps mixed shelf entries aligned when resolving MP_WXS reader urls', async () => {
74
+ mockFetchPrivateApi.mockRejectedValue(new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first'));
75
+ const page = {
76
+ goto: vi.fn().mockResolvedValue(undefined),
77
+ evaluate: vi.fn()
78
+ .mockResolvedValueOnce({
79
+ cacheFound: true,
80
+ rawBooks: [
81
+ { bookId: 'MP_WXS_1', title: '公众号文章一', author: '作者甲' },
82
+ { bookId: 'BOOK_2', title: '普通书二', author: '作者乙' },
83
+ { bookId: 'MP_WXS_3', title: '公众号文章三', author: '作者丙' },
84
+ ],
85
+ shelfIndexes: [
86
+ { bookId: 'MP_WXS_1', idx: 0, role: 'mp' },
87
+ { bookId: 'BOOK_2', idx: 1, role: 'book' },
88
+ { bookId: 'MP_WXS_3', idx: 2, role: 'mp' },
89
+ ],
90
+ })
91
+ .mockResolvedValueOnce([
92
+ 'https://weread.qq.com/web/reader/mp1',
93
+ 'https://weread.qq.com/web/reader/book2',
94
+ 'https://weread.qq.com/web/reader/mp3',
95
+ ])
96
+ .mockResolvedValueOnce({
97
+ title: '公众号文章一',
98
+ author: '作者甲',
99
+ publisher: '微信读书',
100
+ intro: '第一篇文章。',
101
+ category: '',
102
+ rating: '',
103
+ metadataReady: true,
104
+ }),
105
+ getCookies: vi.fn().mockResolvedValue([
106
+ { name: 'wr_vid', value: '70486028', domain: '.weread.qq.com' },
107
+ ]),
108
+ wait: vi.fn().mockResolvedValue(undefined),
109
+ };
110
+ const result = await book.func(page, { 'book-id': 'MP_WXS_1' });
111
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://weread.qq.com/web/shelf');
112
+ expect(page.goto).toHaveBeenNthCalledWith(2, 'https://weread.qq.com/web/reader/mp1');
113
+ expect(result).toEqual([
114
+ {
115
+ title: '公众号文章一',
116
+ author: '作者甲',
117
+ publisher: '微信读书',
118
+ intro: '第一篇文章。',
119
+ category: '',
120
+ rating: '',
121
+ },
122
+ ]);
123
+ });
124
+ it('rethrows AUTH_REQUIRED when shelf ordering is incomplete and reader urls cannot be trusted', async () => {
125
+ mockFetchPrivateApi.mockRejectedValue(new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first'));
126
+ const page = {
127
+ goto: vi.fn().mockResolvedValue(undefined),
128
+ evaluate: vi.fn()
129
+ .mockResolvedValueOnce({
130
+ cacheFound: true,
131
+ rawBooks: [
132
+ { bookId: 'BOOK_1', title: '第一本', author: '作者甲' },
133
+ { bookId: 'BOOK_2', title: '第二本', author: '作者乙' },
134
+ ],
135
+ shelfIndexes: [
136
+ { bookId: 'BOOK_2', idx: 0, role: 'book' },
137
+ ],
138
+ })
139
+ .mockResolvedValueOnce([
140
+ 'https://weread.qq.com/web/reader/book2',
141
+ 'https://weread.qq.com/web/reader/book1',
142
+ ]),
143
+ getCookies: vi.fn().mockResolvedValue([
144
+ { name: 'wr_vid', value: '70486028', domain: '.weread.qq.com' },
145
+ ]),
146
+ wait: vi.fn().mockResolvedValue(undefined),
147
+ };
148
+ await expect(book.func(page, { 'book-id': 'BOOK_1' })).rejects.toMatchObject({
149
+ code: 'AUTH_REQUIRED',
150
+ message: 'Not logged in to WeRead',
151
+ });
152
+ expect(page.goto).toHaveBeenCalledTimes(1);
153
+ expect(page.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
154
+ });
155
+ it('waits for shelf indexes to hydrate before resolving a trusted reader url', async () => {
156
+ mockFetchPrivateApi.mockRejectedValue(new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first'));
157
+ const page = {
158
+ goto: vi.fn().mockResolvedValue(undefined),
159
+ evaluate: vi.fn()
160
+ .mockResolvedValueOnce({
161
+ cacheFound: true,
162
+ rawBooks: [
163
+ { bookId: 'BOOK_1', title: '第一本', author: '作者甲' },
164
+ { bookId: 'BOOK_2', title: '第二本', author: '作者乙' },
165
+ ],
166
+ shelfIndexes: [
167
+ { bookId: 'BOOK_2', idx: 0, role: 'book' },
168
+ ],
169
+ })
170
+ .mockResolvedValueOnce({
171
+ cacheFound: true,
172
+ rawBooks: [
173
+ { bookId: 'BOOK_1', title: '第一本', author: '作者甲' },
174
+ { bookId: 'BOOK_2', title: '第二本', author: '作者乙' },
175
+ ],
176
+ shelfIndexes: [
177
+ { bookId: 'BOOK_2', idx: 0, role: 'book' },
178
+ { bookId: 'BOOK_1', idx: 1, role: 'book' },
179
+ ],
180
+ })
181
+ .mockResolvedValueOnce([
182
+ 'https://weread.qq.com/web/reader/book2',
183
+ 'https://weread.qq.com/web/reader/book1',
184
+ ])
185
+ .mockResolvedValueOnce({
186
+ title: '第一本',
187
+ author: '作者甲',
188
+ publisher: '出版社甲',
189
+ intro: '简介甲',
190
+ category: '',
191
+ rating: '',
192
+ metadataReady: true,
193
+ }),
194
+ getCookies: vi.fn().mockResolvedValue([
195
+ { name: 'wr_vid', value: '70486028', domain: '.weread.qq.com' },
196
+ ]),
197
+ wait: vi.fn().mockResolvedValue(undefined),
198
+ };
199
+ const result = await book.func(page, { 'book-id': 'BOOK_1' });
200
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://weread.qq.com/web/shelf');
201
+ expect(page.goto).toHaveBeenNthCalledWith(2, 'https://weread.qq.com/web/reader/book1');
202
+ expect(result).toEqual([
203
+ {
204
+ title: '第一本',
205
+ author: '作者甲',
206
+ publisher: '出版社甲',
207
+ intro: '简介甲',
208
+ category: '',
209
+ rating: '',
210
+ },
211
+ ]);
212
+ });
213
+ it('rethrows AUTH_REQUIRED when the reader page lacks stable cover metadata', async () => {
214
+ mockFetchPrivateApi.mockRejectedValue(new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first'));
215
+ const page = {
216
+ goto: vi.fn().mockResolvedValue(undefined),
217
+ evaluate: vi.fn()
218
+ .mockResolvedValueOnce({
219
+ cacheFound: true,
220
+ rawBooks: [
221
+ { bookId: 'BOOK_1', title: '第一本', author: '作者甲' },
222
+ ],
223
+ shelfIndexes: [
224
+ { bookId: 'BOOK_1', idx: 0, role: 'book' },
225
+ ],
226
+ })
227
+ .mockResolvedValueOnce([
228
+ 'https://weread.qq.com/web/reader/book1',
229
+ ])
230
+ .mockResolvedValueOnce({
231
+ title: '',
232
+ author: '',
233
+ publisher: '',
234
+ intro: '这是正文第一段,不应该被当成简介。',
235
+ category: '',
236
+ rating: '',
237
+ metadataReady: false,
238
+ }),
239
+ getCookies: vi.fn().mockResolvedValue([
240
+ { name: 'wr_vid', value: '70486028', domain: '.weread.qq.com' },
241
+ ]),
242
+ wait: vi.fn().mockResolvedValue(undefined),
243
+ };
244
+ await expect(book.func(page, { 'book-id': 'BOOK_1' })).rejects.toMatchObject({
245
+ code: 'AUTH_REQUIRED',
246
+ message: 'Not logged in to WeRead',
247
+ });
248
+ });
28
249
  it('passes the positional book-id to highlights', async () => {
29
250
  mockFetchPrivateApi.mockResolvedValue({ updated: [] });
30
251
  await highlights.func({}, { 'book-id': 'abc', limit: 5 });
@@ -0,0 +1 @@
1
+ import './shelf.js';