@jackwener/opencli 1.5.5 → 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 (231) 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 +1229 -67
  7. package/dist/clis/band/bands.d.ts +1 -0
  8. package/dist/clis/band/bands.js +72 -0
  9. package/dist/clis/band/mentions.d.ts +1 -0
  10. package/dist/clis/band/mentions.js +127 -0
  11. package/dist/clis/band/post.d.ts +1 -0
  12. package/dist/clis/band/post.js +175 -0
  13. package/dist/clis/band/posts.d.ts +1 -0
  14. package/dist/clis/band/posts.js +94 -0
  15. package/dist/clis/doubao/detail.d.ts +1 -0
  16. package/dist/clis/doubao/detail.js +33 -0
  17. package/dist/clis/doubao/detail.test.d.ts +1 -0
  18. package/dist/clis/doubao/detail.test.js +42 -0
  19. package/dist/clis/doubao/history.d.ts +1 -0
  20. package/dist/clis/doubao/history.js +28 -0
  21. package/dist/clis/doubao/history.test.d.ts +1 -0
  22. package/dist/clis/doubao/history.test.js +37 -0
  23. package/dist/clis/doubao/meeting-summary.d.ts +1 -0
  24. package/dist/clis/doubao/meeting-summary.js +39 -0
  25. package/dist/clis/doubao/meeting-transcript.d.ts +1 -0
  26. package/dist/clis/doubao/meeting-transcript.js +36 -0
  27. package/dist/clis/doubao/utils.d.ts +27 -0
  28. package/dist/clis/doubao/utils.js +317 -0
  29. package/dist/clis/doubao/utils.test.d.ts +1 -0
  30. package/dist/clis/doubao/utils.test.js +24 -0
  31. package/dist/clis/douyin/_shared/public-api.d.ts +33 -0
  32. package/dist/clis/douyin/_shared/public-api.js +29 -0
  33. package/dist/clis/douyin/user-videos.d.ts +5 -0
  34. package/dist/clis/douyin/user-videos.js +74 -0
  35. package/dist/clis/douyin/user-videos.test.d.ts +1 -0
  36. package/dist/clis/douyin/user-videos.test.js +108 -0
  37. package/dist/clis/ones/common.d.ts +32 -0
  38. package/dist/clis/ones/common.js +144 -0
  39. package/dist/clis/ones/enrich-tasks.d.ts +5 -0
  40. package/dist/clis/ones/enrich-tasks.js +37 -0
  41. package/dist/clis/ones/login.d.ts +1 -0
  42. package/dist/clis/ones/login.js +80 -0
  43. package/dist/clis/ones/logout.d.ts +1 -0
  44. package/dist/clis/ones/logout.js +17 -0
  45. package/dist/clis/ones/me.d.ts +1 -0
  46. package/dist/clis/ones/me.js +30 -0
  47. package/dist/clis/ones/my-tasks.d.ts +1 -0
  48. package/dist/clis/ones/my-tasks.js +120 -0
  49. package/dist/clis/ones/resolve-labels.d.ts +10 -0
  50. package/dist/clis/ones/resolve-labels.js +64 -0
  51. package/dist/clis/ones/task-helpers.d.ts +29 -0
  52. package/dist/clis/ones/task-helpers.js +212 -0
  53. package/dist/clis/ones/task-helpers.test.d.ts +1 -0
  54. package/dist/clis/ones/task-helpers.test.js +12 -0
  55. package/dist/clis/ones/task.d.ts +1 -0
  56. package/dist/clis/ones/task.js +66 -0
  57. package/dist/clis/ones/tasks.d.ts +1 -0
  58. package/dist/clis/ones/tasks.js +79 -0
  59. package/dist/clis/ones/token-info.d.ts +1 -0
  60. package/dist/clis/ones/token-info.js +42 -0
  61. package/dist/clis/ones/worklog.d.ts +11 -0
  62. package/dist/clis/ones/worklog.js +267 -0
  63. package/dist/clis/ones/worklog.test.d.ts +1 -0
  64. package/dist/clis/ones/worklog.test.js +20 -0
  65. package/dist/clis/spotify/spotify.d.ts +1 -0
  66. package/dist/clis/spotify/spotify.js +316 -0
  67. package/dist/clis/spotify/utils.d.ts +21 -0
  68. package/dist/clis/spotify/utils.js +66 -0
  69. package/dist/clis/spotify/utils.test.d.ts +1 -0
  70. package/dist/clis/spotify/utils.test.js +67 -0
  71. package/dist/clis/tieba/commands.test.d.ts +4 -0
  72. package/dist/clis/tieba/commands.test.js +79 -0
  73. package/dist/clis/tieba/hot.d.ts +1 -0
  74. package/dist/clis/tieba/hot.js +48 -0
  75. package/dist/clis/tieba/posts.d.ts +1 -0
  76. package/dist/clis/tieba/posts.js +85 -0
  77. package/dist/clis/tieba/read.d.ts +1 -0
  78. package/dist/clis/tieba/read.js +140 -0
  79. package/dist/clis/tieba/search.d.ts +1 -0
  80. package/dist/clis/tieba/search.js +108 -0
  81. package/dist/clis/tieba/utils.d.ts +101 -0
  82. package/dist/clis/tieba/utils.js +240 -0
  83. package/dist/clis/tieba/utils.test.d.ts +1 -0
  84. package/dist/clis/tieba/utils.test.js +290 -0
  85. package/dist/clis/weread/book.js +100 -13
  86. package/dist/clis/weread/commands.test.js +221 -0
  87. package/dist/clis/weread/private-api-regression.test.d.ts +1 -0
  88. package/dist/{weread-private-api-regression.test.js → clis/weread/private-api-regression.test.js} +92 -30
  89. package/dist/clis/weread/search-regression.test.d.ts +1 -0
  90. package/dist/clis/weread/search-regression.test.js +407 -0
  91. package/dist/clis/weread/search.js +143 -7
  92. package/dist/clis/weread/shelf.js +13 -95
  93. package/dist/clis/weread/utils.d.ts +46 -0
  94. package/dist/clis/weread/utils.js +214 -7
  95. package/dist/clis/weread/utils.test.js +71 -1
  96. package/dist/clis/xiaohongshu/publish.d.ts +1 -1
  97. package/dist/clis/xiaohongshu/publish.js +78 -31
  98. package/dist/clis/xiaohongshu/publish.test.js +66 -1
  99. package/dist/clis/xiaohongshu/user-helpers.d.ts +1 -0
  100. package/dist/clis/xiaohongshu/user-helpers.js +2 -0
  101. package/dist/clis/xiaohongshu/user-helpers.test.js +18 -0
  102. package/dist/clis/xueqiu/comments.d.ts +118 -0
  103. package/dist/clis/xueqiu/comments.js +354 -0
  104. package/dist/clis/xueqiu/comments.test.d.ts +1 -0
  105. package/dist/clis/xueqiu/comments.test.js +696 -0
  106. package/dist/clis/youtube/transcript.js +2 -4
  107. package/dist/clis/youtube/utils.d.ts +9 -0
  108. package/dist/clis/youtube/utils.js +67 -3
  109. package/dist/clis/youtube/utils.test.d.ts +1 -0
  110. package/dist/clis/youtube/utils.test.js +37 -0
  111. package/dist/clis/youtube/video.js +16 -15
  112. package/dist/clis/zsxq/dynamics.d.ts +1 -0
  113. package/dist/clis/zsxq/dynamics.js +47 -0
  114. package/dist/clis/zsxq/groups.d.ts +1 -0
  115. package/dist/clis/zsxq/groups.js +32 -0
  116. package/dist/clis/zsxq/search.d.ts +1 -0
  117. package/dist/clis/zsxq/search.js +43 -0
  118. package/dist/clis/zsxq/search.test.d.ts +1 -0
  119. package/dist/clis/zsxq/search.test.js +24 -0
  120. package/dist/clis/zsxq/topic.d.ts +1 -0
  121. package/dist/clis/zsxq/topic.js +47 -0
  122. package/dist/clis/zsxq/topic.test.d.ts +1 -0
  123. package/dist/clis/zsxq/topic.test.js +29 -0
  124. package/dist/clis/zsxq/topics.d.ts +1 -0
  125. package/dist/clis/zsxq/topics.js +25 -0
  126. package/dist/clis/zsxq/topics.test.d.ts +1 -0
  127. package/dist/clis/zsxq/topics.test.js +24 -0
  128. package/dist/clis/zsxq/utils.d.ts +97 -0
  129. package/dist/clis/zsxq/utils.js +230 -0
  130. package/dist/commanderAdapter.js +1 -1
  131. package/dist/commanderAdapter.test.js +39 -0
  132. package/dist/external-clis.yaml +17 -0
  133. package/dist/types.d.ts +5 -0
  134. package/docs/.vitepress/config.mts +3 -0
  135. package/docs/adapters/browser/band.md +63 -0
  136. package/docs/adapters/browser/ones.md +59 -0
  137. package/docs/adapters/browser/spotify.md +62 -0
  138. package/docs/adapters/browser/tieba.md +45 -0
  139. package/docs/adapters/browser/xueqiu.md +5 -0
  140. package/docs/adapters/browser/zsxq.md +49 -0
  141. package/docs/adapters/index.md +5 -2
  142. package/docs/adapters-doc/ones.md +32 -0
  143. package/extension/src/background.ts +15 -0
  144. package/extension/src/cdp.ts +42 -0
  145. package/extension/src/protocol.ts +5 -1
  146. package/package.json +1 -1
  147. package/scripts/postinstall.js +16 -0
  148. package/src/browser/daemon-client.ts +5 -1
  149. package/src/browser/page.ts +16 -0
  150. package/src/clis/band/bands.ts +76 -0
  151. package/src/clis/band/mentions.ts +134 -0
  152. package/src/clis/band/post.ts +187 -0
  153. package/src/clis/band/posts.ts +106 -0
  154. package/src/clis/doubao/detail.test.ts +53 -0
  155. package/src/clis/doubao/detail.ts +41 -0
  156. package/src/clis/doubao/history.test.ts +45 -0
  157. package/src/clis/doubao/history.ts +32 -0
  158. package/src/clis/doubao/meeting-summary.ts +53 -0
  159. package/src/clis/doubao/meeting-transcript.ts +48 -0
  160. package/src/clis/doubao/utils.test.ts +45 -0
  161. package/src/clis/doubao/utils.ts +371 -0
  162. package/src/clis/douyin/_shared/public-api.ts +84 -0
  163. package/src/clis/douyin/user-videos.test.ts +122 -0
  164. package/src/clis/douyin/user-videos.ts +101 -0
  165. package/src/clis/ones/common.ts +187 -0
  166. package/src/clis/ones/enrich-tasks.ts +47 -0
  167. package/src/clis/ones/login.ts +103 -0
  168. package/src/clis/ones/logout.ts +19 -0
  169. package/src/clis/ones/me.ts +34 -0
  170. package/src/clis/ones/my-tasks.ts +148 -0
  171. package/src/clis/ones/resolve-labels.ts +80 -0
  172. package/src/clis/ones/task-helpers.test.ts +14 -0
  173. package/src/clis/ones/task-helpers.ts +214 -0
  174. package/src/clis/ones/task.ts +79 -0
  175. package/src/clis/ones/tasks.ts +92 -0
  176. package/src/clis/ones/token-info.ts +46 -0
  177. package/src/clis/ones/worklog.test.ts +24 -0
  178. package/src/clis/ones/worklog.ts +306 -0
  179. package/src/clis/spotify/spotify.ts +328 -0
  180. package/src/clis/spotify/utils.test.ts +87 -0
  181. package/src/clis/spotify/utils.ts +92 -0
  182. package/src/clis/tieba/commands.test.ts +86 -0
  183. package/src/clis/tieba/hot.ts +52 -0
  184. package/src/clis/tieba/posts.ts +108 -0
  185. package/src/clis/tieba/read.ts +158 -0
  186. package/src/clis/tieba/search.ts +119 -0
  187. package/src/clis/tieba/utils.test.ts +322 -0
  188. package/src/clis/tieba/utils.ts +348 -0
  189. package/src/clis/weread/book.ts +116 -13
  190. package/src/clis/weread/commands.test.ts +249 -0
  191. package/src/{weread-private-api-regression.test.ts → clis/weread/private-api-regression.test.ts} +108 -30
  192. package/src/clis/weread/search-regression.test.ts +440 -0
  193. package/src/clis/weread/search.ts +189 -9
  194. package/src/clis/weread/shelf.ts +20 -122
  195. package/src/clis/weread/utils.test.ts +81 -1
  196. package/src/clis/weread/utils.ts +264 -7
  197. package/src/clis/xiaohongshu/publish.test.ts +79 -1
  198. package/src/clis/xiaohongshu/publish.ts +84 -30
  199. package/src/clis/xiaohongshu/user-helpers.test.ts +23 -0
  200. package/src/clis/xiaohongshu/user-helpers.ts +4 -0
  201. package/src/clis/xueqiu/comments.test.ts +823 -0
  202. package/src/clis/xueqiu/comments.ts +461 -0
  203. package/src/clis/youtube/transcript.ts +2 -4
  204. package/src/clis/youtube/utils.test.ts +43 -0
  205. package/src/clis/youtube/utils.ts +69 -0
  206. package/src/clis/youtube/video.ts +16 -15
  207. package/src/clis/zsxq/dynamics.ts +60 -0
  208. package/src/clis/zsxq/groups.ts +41 -0
  209. package/src/clis/zsxq/search.test.ts +29 -0
  210. package/src/clis/zsxq/search.ts +54 -0
  211. package/src/clis/zsxq/topic.test.ts +34 -0
  212. package/src/clis/zsxq/topic.ts +68 -0
  213. package/src/clis/zsxq/topics.test.ts +29 -0
  214. package/src/clis/zsxq/topics.ts +36 -0
  215. package/src/clis/zsxq/utils.ts +351 -0
  216. package/src/commanderAdapter.test.ts +47 -0
  217. package/src/commanderAdapter.ts +1 -1
  218. package/src/external-clis.yaml +17 -0
  219. package/src/types.ts +5 -0
  220. package/tests/e2e/band-auth.test.ts +20 -0
  221. package/tests/e2e/browser-auth-helpers.ts +18 -0
  222. package/tests/e2e/browser-auth.test.ts +35 -47
  223. package/tests/e2e/browser-public.test.ts +288 -0
  224. package/tests/e2e/management.test.ts +1 -1
  225. package/tests/e2e/plugin-management.test.ts +1 -1
  226. package/vitest.config.ts +1 -0
  227. package/SKILL.md +0 -879
  228. package/dist/weread-private-api-regression.test.d.ts +0 -1
  229. package/dist/weread-search-regression.test.d.ts +0 -1
  230. package/dist/weread-search-regression.test.js +0 -39
  231. package/src/weread-search-regression.test.ts +0 -44
@@ -0,0 +1,119 @@
1
+ import { ArgumentError, EmptyResultError } from '../../errors.js';
2
+ import { cli, Strategy, type CommandArgs } from '../../registry.js';
3
+ import { buildTiebaSearchItems, type RawTiebaSearchItem, normalizeTiebaLimit } from './utils.js';
4
+
5
+ const MAX_SUPPORTED_PAGE = '1';
6
+
7
+ /**
8
+ * Extract search result cards from tieba's current desktop search page.
9
+ */
10
+ function buildExtractSearchResultsEvaluate(limit: number): string {
11
+ return `
12
+ (async () => {
13
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+ const waitFor = async (predicate, timeoutMs = 5000) => {
15
+ const start = Date.now();
16
+ while (Date.now() - start < timeoutMs) {
17
+ if (predicate()) return true;
18
+ await wait(100);
19
+ }
20
+ return false;
21
+ };
22
+ const getVueProps = (element) => {
23
+ const vue = element && element.__vue__ ? element.__vue__ : null;
24
+ return vue ? (vue._props || vue.$props || {}) : {};
25
+ };
26
+ await waitFor(() => {
27
+ const bodyText = document.body?.innerText || '';
28
+ return Boolean(
29
+ document.querySelector('.threadcardclass.thread-new3.index-feed-cards')
30
+ || document.querySelector('.search-no-result, .search-nodata, .no-result')
31
+ || /百度安全验证|安全验证|请完成验证/.test(bodyText)
32
+ );
33
+ });
34
+ const items = document.querySelectorAll('.threadcardclass.thread-new3.index-feed-cards');
35
+ return Array.from(items).slice(0, ${limit}).map((item) => {
36
+ const forum = item.querySelector('.forum-name-text, .forum-name')?.textContent?.trim() || '';
37
+ const meta = item.querySelector('.user-forum-info')?.textContent?.replace(/\\s+/g, ' ').trim() || '';
38
+ const metaWithoutForum = forum && meta.startsWith(forum)
39
+ ? meta.slice(forum.length).trim()
40
+ : meta;
41
+ const metaMatch = metaWithoutForum.match(/^(.*?)\\s*发布于\\s*(.+)$/);
42
+ const actionBar = item.querySelector('.action-bar-container.search-action-bar');
43
+ const businessInfo = getVueProps(actionBar).businessInfo || {};
44
+ const href = item.querySelector('a[href*="/p/"]')?.href || '';
45
+ const threadId = String(businessInfo.thread_id || '').trim();
46
+ const title = item.querySelector('.title-wrap')?.textContent?.trim()
47
+ || item.querySelector('.title-content-wrap')?.textContent?.trim()
48
+ || '';
49
+ const snippet = item.querySelector('.title-content-wrap')?.textContent?.trim()
50
+ || item.querySelector('.abstract-wrap')?.textContent?.trim()
51
+ || '';
52
+
53
+ return {
54
+ title,
55
+ forum,
56
+ author: metaMatch ? metaMatch[1].trim() : metaWithoutForum,
57
+ time: metaMatch ? metaMatch[2].trim() : '',
58
+ snippet: snippet.substring(0, 200),
59
+ id: threadId,
60
+ url: href || (threadId ? 'https://tieba.baidu.com/p/' + threadId : ''),
61
+ };
62
+ }).filter((item) => item.title);
63
+ })()
64
+ `;
65
+ }
66
+
67
+ /**
68
+ * Normalize CLI args into the concrete search page URL.
69
+ */
70
+ function getSearchUrl(kwargs: CommandArgs): string {
71
+ const keyword = String(kwargs.keyword || '');
72
+ const pageNumber = Number(kwargs.page || 1);
73
+ return `https://tieba.baidu.com/f/search/res?qw=${encodeURIComponent(keyword)}&ie=utf-8&pn=${pageNumber}`;
74
+ }
75
+
76
+ /**
77
+ * Tieba's current desktop search UI no longer exposes a reliable browser-page transition.
78
+ */
79
+ function assertSupportedPage(kwargs: CommandArgs): void {
80
+ const pageNumber = String(kwargs.page || 1);
81
+ if (pageNumber === MAX_SUPPORTED_PAGE) return;
82
+
83
+ throw new ArgumentError(
84
+ `tieba search currently only supports --page ${MAX_SUPPORTED_PAGE}`,
85
+ `Baidu Tieba search no longer exposes stable browser pagination; omit --page or use --page ${MAX_SUPPORTED_PAGE}`,
86
+ );
87
+ }
88
+
89
+ cli({
90
+ site: 'tieba',
91
+ name: 'search',
92
+ description: 'Search posts across tieba',
93
+ domain: 'tieba.baidu.com',
94
+ strategy: Strategy.COOKIE,
95
+ browser: true,
96
+ navigateBefore: false,
97
+ args: [
98
+ { name: 'keyword', positional: true, required: true, type: 'string', help: 'Search keyword' },
99
+ // Restrict unsupported pages before the browser session starts.
100
+ { name: 'page', type: 'int', default: 1, choices: ['1'], help: 'Page number (currently only 1 is supported)' },
101
+ { name: 'limit', type: 'int', default: 20, help: 'Number of items to return' },
102
+ ],
103
+ columns: ['rank', 'title', 'forum', 'author', 'time'],
104
+ func: async (page, kwargs) => {
105
+ assertSupportedPage(kwargs);
106
+
107
+ const limit = normalizeTiebaLimit(kwargs.limit);
108
+ // Use the default browser settle path so we do not read a stale page.
109
+ await page.goto(getSearchUrl(kwargs));
110
+
111
+ const raw = await page.evaluate(buildExtractSearchResultsEvaluate(limit));
112
+ const items = buildTiebaSearchItems(Array.isArray(raw) ? raw as RawTiebaSearchItem[] : [], limit);
113
+ if (!items.length) {
114
+ throw new EmptyResultError('tieba search', 'Tieba may have blocked the result page, or the DOM structure may have changed');
115
+ }
116
+
117
+ return items;
118
+ },
119
+ });
@@ -0,0 +1,322 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ MAX_TIEBA_LIMIT,
4
+ buildTiebaPostCardsFromPagePc,
5
+ buildTiebaPostItems,
6
+ buildTiebaSearchItems,
7
+ buildTiebaReadItems,
8
+ normalizeTiebaLimit,
9
+ signTiebaPcParams,
10
+ } from './utils.js';
11
+
12
+ describe('normalizeTiebaLimit', () => {
13
+ it('caps list commands at the declared tieba maximum', () => {
14
+ expect(MAX_TIEBA_LIMIT).toBe(20);
15
+ expect(normalizeTiebaLimit(undefined)).toBe(20);
16
+ expect(normalizeTiebaLimit(25)).toBe(20);
17
+ expect(normalizeTiebaLimit(7)).toBe(7);
18
+ });
19
+ });
20
+
21
+ describe('signTiebaPcParams', () => {
22
+ it('matches Tieba PC forum-list signing for stable page_pc requests', () => {
23
+ expect(signTiebaPcParams({
24
+ kw: encodeURIComponent('李毅'),
25
+ pn: '1',
26
+ sort_type: '-1',
27
+ is_newfrs: '1',
28
+ is_newfeed: '1',
29
+ rn: '30',
30
+ rn_need: '20',
31
+ tbs: '',
32
+ subapp_type: 'pc',
33
+ _client_type: '20',
34
+ })).toBe('466f2e091dd4ed17c6661a842b5ec342');
35
+ });
36
+ });
37
+
38
+ describe('buildTiebaPostCardsFromPagePc', () => {
39
+ it('extracts thread cards from signed page_pc feed payloads', () => {
40
+ const cards = buildTiebaPostCardsFromPagePc([
41
+ {
42
+ layout: 'feed',
43
+ feed: {
44
+ schema: 'tiebaapp://router/portal?params=%7B%22pageParams%22%3A%7B%22tid%22%3A10596901456%7D%7D',
45
+ log_param: [
46
+ { key: 'tid', value: '10596901456' },
47
+ ],
48
+ business_info_map: {
49
+ thread_id: '10596901456',
50
+ title: '崇拜希特勒的人都是日本的汉奸走狗',
51
+ },
52
+ components: [
53
+ {
54
+ component: 'feed_head',
55
+ feed_head: {
56
+ extra_data: [
57
+ {
58
+ business_info_map: { time_prefix: '回复于' },
59
+ text: { text: '1774343231' },
60
+ },
61
+ ],
62
+ main_data: [
63
+ {
64
+ text: { text: '上帝的子民º♬' },
65
+ },
66
+ ],
67
+ },
68
+ },
69
+ {
70
+ component: 'feed_title',
71
+ feed_title: {
72
+ data: [{ text_info: { text: '崇拜希特勒的人都是日本的汉奸走狗' } }],
73
+ },
74
+ },
75
+ {
76
+ component: 'feed_social',
77
+ feed_social: {
78
+ comment_num: 12,
79
+ },
80
+ },
81
+ ],
82
+ },
83
+ },
84
+ ]);
85
+
86
+ expect(cards).toEqual([
87
+ {
88
+ title: '崇拜希特勒的人都是日本的汉奸走狗',
89
+ author: '上帝的子民º♬',
90
+ descInfo: '回复于2026-03-24 17:07',
91
+ commentCount: 12,
92
+ actionTexts: [],
93
+ threadId: '10596901456',
94
+ url: 'https://tieba.baidu.com/p/10596901456',
95
+ },
96
+ ]);
97
+ });
98
+ });
99
+
100
+ describe('buildTiebaPostItems', () => {
101
+ it('builds stable thread ids and urls from card props without page hops', () => {
102
+ const items = buildTiebaPostItems([
103
+ {
104
+ title: '我来说个事',
105
+ author: '暴躁的小伙子',
106
+ descInfo: '回复于2分钟前',
107
+ actionTexts: ['分享', '评论 5', '点赞 2'],
108
+ threadId: '10590564788',
109
+ },
110
+ ], 5);
111
+
112
+ expect(items).toEqual([
113
+ {
114
+ rank: 1,
115
+ title: '我来说个事',
116
+ author: '暴躁的小伙子',
117
+ replies: 5,
118
+ last_reply: '2分钟前',
119
+ id: '10590564788',
120
+ url: 'https://tieba.baidu.com/p/10590564788',
121
+ },
122
+ ]);
123
+ });
124
+
125
+ it('honors the public 20-item limit contract', () => {
126
+ const raw = Array.from({ length: 25 }, (_, index) => ({
127
+ title: `帖子 ${index + 1}`,
128
+ author: `作者 ${index + 1}`,
129
+ descInfo: '回复于刚刚',
130
+ actionTexts: ['分享', `评论 ${index + 1}`],
131
+ threadId: String(1000 + index),
132
+ }));
133
+
134
+ const items = buildTiebaPostItems(raw, 25);
135
+ expect(items).toHaveLength(20);
136
+ expect(items[19]).toMatchObject({
137
+ rank: 20,
138
+ id: '1019',
139
+ url: 'https://tieba.baidu.com/p/1019',
140
+ });
141
+ });
142
+
143
+ it('parses Chinese count units and keeps date-time last-reply text intact', () => {
144
+ const items = buildTiebaPostItems([
145
+ {
146
+ title: '复杂格式帖子',
147
+ author: '作者',
148
+ descInfo: '回复于03-29 11:35',
149
+ actionTexts: ['分享', '评论 1.2万'],
150
+ url: 'https://tieba.baidu.com/p/123456',
151
+ },
152
+ ], 5);
153
+
154
+ expect(items[0]).toMatchObject({
155
+ replies: 12000,
156
+ last_reply: '03-29 11:35',
157
+ id: '123456',
158
+ url: 'https://tieba.baidu.com/p/123456',
159
+ });
160
+ });
161
+ });
162
+
163
+ describe('buildTiebaSearchItems', () => {
164
+ it('keeps up to 20 search results when the page provides more than 10 cards', () => {
165
+ const raw = Array.from({ length: 25 }, (_, index) => ({
166
+ title: `结果 ${index + 1}`,
167
+ forum: '编程吧',
168
+ author: `作者 ${index + 1}`,
169
+ time: '2026-03-29',
170
+ snippet: `摘要 ${index + 1}`,
171
+ id: String(2000 + index),
172
+ url: `https://tieba.baidu.com/p/${2000 + index}`,
173
+ }));
174
+
175
+ const items = buildTiebaSearchItems(raw, 25);
176
+ expect(items).toHaveLength(20);
177
+ expect(items[19]).toMatchObject({
178
+ rank: 20,
179
+ id: '2019',
180
+ url: 'https://tieba.baidu.com/p/2019',
181
+ });
182
+ });
183
+
184
+ it('fills missing search ids from stable thread urls', () => {
185
+ const items = buildTiebaSearchItems([
186
+ {
187
+ title: '搜索结果',
188
+ forum: '编程吧',
189
+ author: '作者',
190
+ time: '2026-03-29 11:35',
191
+ snippet: '摘要',
192
+ id: '',
193
+ url: 'https://tieba.baidu.com/p/654321',
194
+ },
195
+ ], 5);
196
+
197
+ expect(items[0]).toMatchObject({
198
+ id: '654321',
199
+ url: 'https://tieba.baidu.com/p/654321',
200
+ });
201
+ });
202
+ });
203
+
204
+ describe('buildTiebaReadItems', () => {
205
+ it('prefers visible main-post fields and still keeps floor 1 for media-only threads', () => {
206
+ const items = buildTiebaReadItems({
207
+ mainPost: {
208
+ title: '刚开始读博士的人据说都这样',
209
+ author: '湖水之岸',
210
+ contentText: '',
211
+ structuredText: '',
212
+ visibleTime: '03-24',
213
+ structuredTime: 1774343231,
214
+ hasMedia: true,
215
+ },
216
+ replies: [],
217
+ }, { limit: 5, includeMainPost: true });
218
+
219
+ expect(items).toEqual([
220
+ {
221
+ floor: 1,
222
+ author: '湖水之岸',
223
+ content: '刚开始读博士的人据说都这样 [media]',
224
+ time: '03-24',
225
+ },
226
+ ]);
227
+ });
228
+
229
+ it('falls back to structured main-post data when visible text is missing', () => {
230
+ const items = buildTiebaReadItems({
231
+ mainPost: {
232
+ title: '标题',
233
+ author: '',
234
+ fallbackAuthor: '结构化作者',
235
+ contentText: '',
236
+ structuredText: '结构化正文',
237
+ visibleTime: '',
238
+ structuredTime: 1774343231,
239
+ hasMedia: false,
240
+ },
241
+ replies: [
242
+ { floor: 2, author: '回复者', content: '二楼内容', time: '第2楼 2026-03-25 12:34 广东' },
243
+ ],
244
+ }, { limit: 5, includeMainPost: true });
245
+
246
+ expect(items[0]).toMatchObject({
247
+ floor: 1,
248
+ author: '结构化作者',
249
+ content: '标题 结构化正文',
250
+ time: '2026-03-24 17:07',
251
+ });
252
+ expect(items[1]).toMatchObject({
253
+ floor: 2,
254
+ author: '回复者',
255
+ content: '二楼内容',
256
+ time: '2026-03-25 12:34',
257
+ });
258
+ });
259
+
260
+ it('strips trailing location metadata from reply times', () => {
261
+ const items = buildTiebaReadItems({
262
+ mainPost: {
263
+ title: '主楼',
264
+ author: '楼主',
265
+ contentText: '正文',
266
+ visibleTime: '03-24',
267
+ },
268
+ replies: [
269
+ { floor: 2, author: '二楼', content: '二楼内容', time: '第2楼 3小时前 福建' },
270
+ { floor: 3, author: '三楼', content: '三楼内容', time: '第3楼 刚刚 江苏' },
271
+ ],
272
+ }, { limit: 5, includeMainPost: false });
273
+
274
+ expect(items).toEqual([
275
+ {
276
+ floor: 2,
277
+ author: '二楼',
278
+ content: '二楼内容',
279
+ time: '3小时前',
280
+ },
281
+ {
282
+ floor: 3,
283
+ author: '三楼',
284
+ content: '三楼内容',
285
+ time: '刚刚',
286
+ },
287
+ ]);
288
+ });
289
+
290
+ it('counts limit as replies and skips main post on later pages', () => {
291
+ const items = buildTiebaReadItems({
292
+ mainPost: {
293
+ title: '主楼',
294
+ author: '楼主',
295
+ contentText: '正文',
296
+ visibleTime: '03-24',
297
+ },
298
+ replies: [
299
+ { floor: 2, author: '二楼', content: '二楼内容', time: '第2楼 03-25' },
300
+ { floor: 3, author: '三楼', content: '三楼内容', time: '第3楼 03-26' },
301
+ { floor: 4, author: '四楼', content: '四楼内容', time: '第4楼 03-27' },
302
+ ],
303
+ }, { limit: 2, includeMainPost: true });
304
+
305
+ expect(items).toHaveLength(3);
306
+ expect(items.map((item) => item.floor)).toEqual([1, 2, 3]);
307
+
308
+ const page2 = buildTiebaReadItems({
309
+ mainPost: {
310
+ title: '主楼',
311
+ author: '楼主',
312
+ contentText: '正文',
313
+ visibleTime: '03-24',
314
+ },
315
+ replies: [
316
+ { floor: 26, author: '二十六楼', content: '二十六楼内容', time: '第26楼 03-29' },
317
+ ],
318
+ }, { limit: 2, includeMainPost: false });
319
+
320
+ expect(page2.map((item) => item.floor)).toEqual([26]);
321
+ });
322
+ });