@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,66 @@
1
+ import { CliError } from '../../errors.js';
2
+ const SPOTIFY_PLACEHOLDER_PATTERNS = [
3
+ /^your_spotify_client_id_here$/i,
4
+ /^your_spotify_client_secret_here$/i,
5
+ /^your_.+_here$/i,
6
+ ];
7
+ export function parseDotEnv(content) {
8
+ return Object.fromEntries(content
9
+ .split(/\r?\n/)
10
+ .map(line => line.trim())
11
+ .filter(line => line && !line.startsWith('#') && line.includes('='))
12
+ .map(line => {
13
+ const index = line.indexOf('=');
14
+ return [line.slice(0, index).trim(), line.slice(index + 1).trim()];
15
+ }));
16
+ }
17
+ export function resolveSpotifyCredentials(fileEnv, processEnv = process.env) {
18
+ return {
19
+ clientId: processEnv.SPOTIFY_CLIENT_ID || fileEnv.SPOTIFY_CLIENT_ID || '',
20
+ clientSecret: processEnv.SPOTIFY_CLIENT_SECRET || fileEnv.SPOTIFY_CLIENT_SECRET || '',
21
+ };
22
+ }
23
+ export function isPlaceholderCredential(value) {
24
+ const normalized = value?.trim() || '';
25
+ if (!normalized)
26
+ return false;
27
+ return SPOTIFY_PLACEHOLDER_PATTERNS.some(pattern => pattern.test(normalized));
28
+ }
29
+ export function hasConfiguredSpotifyCredentials(credentials) {
30
+ return Boolean(credentials.clientId.trim()) &&
31
+ Boolean(credentials.clientSecret.trim()) &&
32
+ !isPlaceholderCredential(credentials.clientId) &&
33
+ !isPlaceholderCredential(credentials.clientSecret);
34
+ }
35
+ export function assertSpotifyCredentialsConfigured(credentials, envFile) {
36
+ if (hasConfiguredSpotifyCredentials(credentials))
37
+ return;
38
+ throw new CliError('CONFIG', `Missing Spotify credentials.\n\n` +
39
+ `1. Go to https://developer.spotify.com/dashboard and create an app\n` +
40
+ `2. Add ${'http://127.0.0.1:8888/callback'} as a Redirect URI\n` +
41
+ `3. Copy your Client ID and Client Secret\n` +
42
+ `4. Open the file: ${envFile}\n` +
43
+ `5. Fill in SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET, then save\n` +
44
+ `6. Run: opencli spotify auth`);
45
+ }
46
+ export function mapSpotifyTrackResults(data) {
47
+ const items = data?.tracks?.items;
48
+ if (!Array.isArray(items))
49
+ return [];
50
+ return items.map((track) => ({
51
+ track: track?.name || '',
52
+ artist: Array.isArray(track?.artists) ? track.artists.map((artist) => artist.name).join(', ') : '',
53
+ album: track?.album?.name || '',
54
+ uri: track?.uri || '',
55
+ }));
56
+ }
57
+ export function getFirstSpotifyTrack(data) {
58
+ const track = mapSpotifyTrackResults(data)[0];
59
+ if (!track)
60
+ return null;
61
+ return {
62
+ uri: track.uri,
63
+ name: track.track,
64
+ artist: track.artist,
65
+ };
66
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { assertSpotifyCredentialsConfigured, getFirstSpotifyTrack, hasConfiguredSpotifyCredentials, mapSpotifyTrackResults, parseDotEnv, resolveSpotifyCredentials, } from './utils.js';
3
+ describe('spotify utils', () => {
4
+ it('parses dotenv-style credential files', () => {
5
+ const env = parseDotEnv(`
6
+ # Spotify credentials
7
+ SPOTIFY_CLIENT_ID=abc123
8
+ SPOTIFY_CLIENT_SECRET=def456
9
+ `);
10
+ expect(env).toEqual({
11
+ SPOTIFY_CLIENT_ID: 'abc123',
12
+ SPOTIFY_CLIENT_SECRET: 'def456',
13
+ });
14
+ });
15
+ it('prefers explicit process env over file values', () => {
16
+ const credentials = resolveSpotifyCredentials({
17
+ SPOTIFY_CLIENT_ID: 'file-id',
18
+ SPOTIFY_CLIENT_SECRET: 'file-secret',
19
+ }, {
20
+ SPOTIFY_CLIENT_ID: 'env-id',
21
+ SPOTIFY_CLIENT_SECRET: 'env-secret',
22
+ });
23
+ expect(credentials).toEqual({
24
+ clientId: 'env-id',
25
+ clientSecret: 'env-secret',
26
+ });
27
+ });
28
+ it('treats placeholder values as unconfigured credentials', () => {
29
+ expect(hasConfiguredSpotifyCredentials({
30
+ clientId: 'your_spotify_client_id_here',
31
+ clientSecret: 'your_spotify_client_secret_here',
32
+ })).toBe(false);
33
+ });
34
+ it('throws a helpful CONFIG error for empty or placeholder credentials', () => {
35
+ expect(() => assertSpotifyCredentialsConfigured({
36
+ clientId: '',
37
+ clientSecret: '',
38
+ }, '/tmp/spotify.env')).toThrow(/Missing Spotify credentials/);
39
+ expect(() => assertSpotifyCredentialsConfigured({
40
+ clientId: 'your_spotify_client_id_here',
41
+ clientSecret: 'real-secret',
42
+ }, '/tmp/spotify.env')).toThrow(/Fill in SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET/);
43
+ });
44
+ it('maps search payloads into stable track summaries', () => {
45
+ const results = mapSpotifyTrackResults({
46
+ tracks: {
47
+ items: [
48
+ {
49
+ name: 'Numb',
50
+ artists: [{ name: 'Linkin Park' }, { name: 'Jay-Z' }],
51
+ album: { name: 'Encore' },
52
+ uri: 'spotify:track:123',
53
+ },
54
+ ],
55
+ },
56
+ });
57
+ expect(results).toEqual([
58
+ {
59
+ track: 'Numb',
60
+ artist: 'Linkin Park, Jay-Z',
61
+ album: 'Encore',
62
+ uri: 'spotify:track:123',
63
+ },
64
+ ]);
65
+ expect(getFirstSpotifyTrack({ tracks: { items: [] } })).toBeNull();
66
+ });
67
+ });
@@ -0,0 +1,4 @@
1
+ import './hot.js';
2
+ import './posts.js';
3
+ import './read.js';
4
+ import './search.js';
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Strategy, getRegistry } from '../../registry.js';
3
+ import './hot.js';
4
+ import './posts.js';
5
+ import './read.js';
6
+ import './search.js';
7
+ describe('tieba commands', () => {
8
+ it('registers all tieba commands as TypeScript adapters', () => {
9
+ const hot = getRegistry().get('tieba/hot');
10
+ const posts = getRegistry().get('tieba/posts');
11
+ const search = getRegistry().get('tieba/search');
12
+ const read = getRegistry().get('tieba/read');
13
+ expect(hot).toBeDefined();
14
+ expect(posts).toBeDefined();
15
+ expect(search).toBeDefined();
16
+ expect(read).toBeDefined();
17
+ expect(typeof hot?.func).toBe('function');
18
+ expect(typeof posts?.func).toBe('function');
19
+ expect(typeof search?.func).toBe('function');
20
+ expect(typeof read?.func).toBe('function');
21
+ });
22
+ it('keeps the intended browser strategies', () => {
23
+ const hot = getRegistry().get('tieba/hot');
24
+ const posts = getRegistry().get('tieba/posts');
25
+ const search = getRegistry().get('tieba/search');
26
+ const read = getRegistry().get('tieba/read');
27
+ expect(hot?.strategy).toBe(Strategy.PUBLIC);
28
+ expect(posts?.strategy).toBe(Strategy.COOKIE);
29
+ expect(search?.strategy).toBe(Strategy.COOKIE);
30
+ expect(read?.strategy).toBe(Strategy.COOKIE);
31
+ expect(hot?.browser).toBe(true);
32
+ expect(posts?.browser).toBe(true);
33
+ expect(search?.browser).toBe(true);
34
+ expect(read?.browser).toBe(true);
35
+ });
36
+ it('keeps the public limit contract at 20 items for list commands', () => {
37
+ const hot = getRegistry().get('tieba/hot');
38
+ const posts = getRegistry().get('tieba/posts');
39
+ const search = getRegistry().get('tieba/search');
40
+ expect(hot?.args.find((arg) => arg.name === 'limit')?.default).toBe(20);
41
+ expect(posts?.args.find((arg) => arg.name === 'limit')?.default).toBe(20);
42
+ expect(search?.args.find((arg) => arg.name === 'limit')?.default).toBe(20);
43
+ });
44
+ it('rejects tieba read results when navigation lands on the wrong page number', async () => {
45
+ const read = getRegistry().get('tieba/read');
46
+ expect(read).toBeDefined();
47
+ expect(typeof read?.func).toBe('function');
48
+ const run = read?.func;
49
+ if (!run)
50
+ throw new Error('tieba/read did not register a handler');
51
+ const page = {
52
+ goto: async () => undefined,
53
+ evaluate: async () => ({
54
+ pageMeta: {
55
+ pathname: '/p/10163164720',
56
+ pn: '1',
57
+ },
58
+ mainPost: {
59
+ title: '测试帖子',
60
+ author: '作者',
61
+ contentText: '正文',
62
+ structuredText: '',
63
+ visibleTime: '2026-03-29 12:00',
64
+ structuredTime: 0,
65
+ hasMedia: false,
66
+ },
67
+ replies: [],
68
+ }),
69
+ };
70
+ await expect(run(page, {
71
+ id: '10163164720',
72
+ page: 2,
73
+ limit: 5,
74
+ })).rejects.toMatchObject({
75
+ code: 'EMPTY_RESULT',
76
+ hint: expect.stringMatching(/requested page/i),
77
+ });
78
+ });
79
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ import { EmptyResultError } from '../../errors.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import { normalizeTiebaLimit } from './utils.js';
4
+ cli({
5
+ site: 'tieba',
6
+ name: 'hot',
7
+ description: 'Tieba hot topics',
8
+ domain: 'tieba.baidu.com',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: true,
11
+ navigateBefore: false,
12
+ args: [
13
+ { name: 'limit', type: 'int', default: 20, help: 'Number of items to return' },
14
+ ],
15
+ columns: ['rank', 'title', 'discussions', 'description'],
16
+ func: async (page, kwargs) => {
17
+ const limit = normalizeTiebaLimit(kwargs.limit);
18
+ // Use the default browser settle path so we do not scrape the previous page.
19
+ await page.goto('https://tieba.baidu.com/hottopic/browse/topicList?res_type=1');
20
+ const raw = await page.evaluate(`(() => {
21
+ const items = document.querySelectorAll('li.topic-top-item');
22
+ return Array.from(items).map((item) => {
23
+ const titleEl = item.querySelector('a.topic-text');
24
+ const numEl = item.querySelector('span.topic-num');
25
+ const descEl = item.querySelector('p.topic-top-item-desc');
26
+ const href = titleEl?.getAttribute('href') || '';
27
+
28
+ return {
29
+ title: titleEl?.textContent?.trim() || '',
30
+ discussions: numEl?.textContent?.trim() || '',
31
+ description: descEl?.textContent?.trim() || '',
32
+ url: href.startsWith('http') ? href : 'https://tieba.baidu.com' + href,
33
+ };
34
+ }).filter((item) => item.title).slice(0, ${limit});
35
+ })()`);
36
+ const items = Array.isArray(raw) ? raw : [];
37
+ if (!items.length) {
38
+ throw new EmptyResultError('tieba hot', 'Tieba may have blocked the hot page, or the DOM structure may have changed');
39
+ }
40
+ return items.map((item, index) => ({
41
+ rank: index + 1,
42
+ title: item.title || '',
43
+ discussions: item.discussions || '',
44
+ description: item.description || '',
45
+ url: item.url || '',
46
+ }));
47
+ },
48
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ import { EmptyResultError } from '../../errors.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import { buildTiebaPostCardsFromPagePc, buildTiebaPostItems, normalizeTiebaLimit, signTiebaPcParams, } from './utils.js';
4
+ function getForumPageNumber(kwargs) {
5
+ return Math.max(1, Number(kwargs.page || 1));
6
+ }
7
+ function getForumUrl(kwargs) {
8
+ const forum = String(kwargs.forum || '');
9
+ return `https://tieba.baidu.com/f?kw=${encodeURIComponent(forum)}&ie=utf-8&pn=${(getForumPageNumber(kwargs) - 1) * 50}`;
10
+ }
11
+ /**
12
+ * Rebuild the signed page_pc request instead of scraping only the visible thread cards.
13
+ */
14
+ function buildTiebaPagePcParams(kwargs, limit) {
15
+ return {
16
+ kw: encodeURIComponent(String(kwargs.forum || '')),
17
+ pn: String(getForumPageNumber(kwargs)),
18
+ sort_type: '-1',
19
+ is_newfrs: '1',
20
+ is_newfeed: '1',
21
+ rn: '30',
22
+ rn_need: String(Math.min(Math.max(limit + 10, 10), 30)),
23
+ tbs: '',
24
+ subapp_type: 'pc',
25
+ _client_type: '20',
26
+ };
27
+ }
28
+ /**
29
+ * Tieba expects the signed forum-list request to be replayed with the browser's cookies.
30
+ */
31
+ async function fetchTiebaPagePc(page, kwargs, limit) {
32
+ await page.goto(getForumUrl(kwargs), { waitUntil: 'none' });
33
+ await page.wait(2);
34
+ const params = buildTiebaPagePcParams(kwargs, limit);
35
+ const cookies = await page.getCookies({ domain: 'tieba.baidu.com' });
36
+ const cookieHeader = cookies.map((item) => `${item.name}=${item.value}`).join('; ');
37
+ const body = new URLSearchParams({
38
+ ...params,
39
+ sign: signTiebaPcParams(params),
40
+ }).toString();
41
+ const response = await fetch('https://tieba.baidu.com/c/f/frs/page_pc', {
42
+ method: 'POST',
43
+ headers: {
44
+ 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
45
+ cookie: cookieHeader,
46
+ 'x-requested-with': 'XMLHttpRequest',
47
+ referer: getForumUrl(kwargs),
48
+ 'user-agent': 'Mozilla/5.0',
49
+ },
50
+ body,
51
+ });
52
+ const text = await response.text();
53
+ try {
54
+ return JSON.parse(text);
55
+ }
56
+ catch {
57
+ return {};
58
+ }
59
+ }
60
+ cli({
61
+ site: 'tieba',
62
+ name: 'posts',
63
+ description: 'Browse posts in a tieba forum',
64
+ domain: 'tieba.baidu.com',
65
+ strategy: Strategy.COOKIE,
66
+ browser: true,
67
+ navigateBefore: false,
68
+ args: [
69
+ { name: 'forum', positional: true, required: true, type: 'string', help: 'Forum name in Chinese' },
70
+ { name: 'page', type: 'int', default: 1, help: 'Page number' },
71
+ { name: 'limit', type: 'int', default: 20, help: 'Number of items to return' },
72
+ ],
73
+ columns: ['rank', 'title', 'author', 'replies'],
74
+ func: async (page, kwargs) => {
75
+ const limit = normalizeTiebaLimit(kwargs.limit);
76
+ const payload = await fetchTiebaPagePc(page, kwargs, limit);
77
+ const rawFeeds = Array.isArray(payload.page_data?.feed_list) ? payload.page_data.feed_list : [];
78
+ const rawCards = buildTiebaPostCardsFromPagePc(rawFeeds);
79
+ const items = buildTiebaPostItems(rawCards, limit);
80
+ if (!items.length || payload.error_code) {
81
+ throw new EmptyResultError('tieba posts', 'Tieba may have blocked the forum page, or the DOM structure may have changed');
82
+ }
83
+ return items;
84
+ },
85
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,140 @@
1
+ import { EmptyResultError } from '../../errors.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import { buildTiebaReadItems } from './utils.js';
4
+ function getThreadUrl(kwargs) {
5
+ const threadId = String(kwargs.id || '');
6
+ const pageNumber = Math.max(1, Number(kwargs.page || 1));
7
+ return `https://tieba.baidu.com/p/${encodeURIComponent(threadId)}?pn=${pageNumber}`;
8
+ }
9
+ /**
10
+ * Ensure the browser actually landed on the requested thread page before we trust the DOM.
11
+ */
12
+ function assertTiebaReadTargetPage(raw, kwargs) {
13
+ const expectedThreadId = String(kwargs.id || '').trim();
14
+ const expectedPageNumber = Math.max(1, Number(kwargs.page || 1));
15
+ const pathname = String(raw.pageMeta?.pathname || '').trim();
16
+ const actualThreadId = pathname.match(/^\/p\/(\d+)/)?.[1] || '';
17
+ const actualPn = String(raw.pageMeta?.pn || '').trim();
18
+ if (!actualThreadId || actualThreadId !== expectedThreadId) {
19
+ throw new EmptyResultError('tieba read', 'Tieba did not land on the requested thread page');
20
+ }
21
+ if (expectedPageNumber > 1 && actualPn !== String(expectedPageNumber)) {
22
+ throw new EmptyResultError('tieba read', 'Tieba did not land on the requested page');
23
+ }
24
+ }
25
+ function buildExtractReadEvaluate() {
26
+ return `
27
+ (async () => {
28
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
+ const waitFor = async (predicate, timeoutMs = 4000) => {
30
+ const start = Date.now();
31
+ while (Date.now() - start < timeoutMs) {
32
+ if (predicate()) return true;
33
+ await wait(100);
34
+ }
35
+ return false;
36
+ };
37
+ const normalizeText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
38
+ const getVueProps = (element) => {
39
+ const vue = element && element.__vue__ ? element.__vue__ : null;
40
+ return vue ? (vue._props || vue.$props || {}) : {};
41
+ };
42
+ const extractStructuredText = (content) => {
43
+ if (!Array.isArray(content)) return '';
44
+ return content
45
+ .map((part) => (part && typeof part === 'object' && typeof part.text === 'string') ? part.text : '')
46
+ .join('')
47
+ .replace(/\\s+/g, ' ')
48
+ .trim();
49
+ };
50
+ const parseFloor = (text) => {
51
+ const match = (text || '').match(/第(\\d+)楼/);
52
+ return match ? parseInt(match[1], 10) : 0;
53
+ };
54
+
55
+ await waitFor(() => {
56
+ const hasMainTree = document.querySelector('.pb-title-wrap.pc-pb-title') || document.querySelector('.pb-content-wrap');
57
+ return Boolean(hasMainTree || document.querySelector('.pb-comment-item'));
58
+ });
59
+
60
+ const titleNode = document.querySelector('.pb-title-wrap.pc-pb-title');
61
+ const titleProps = getVueProps(titleNode);
62
+ const mainUser = document.querySelector('.head-line.user-info:not(.no-extra-margin)');
63
+ const mainUserProps = getVueProps(mainUser);
64
+ const contentWrap = document.querySelector('.pb-content-wrap');
65
+ const contentProps = getVueProps(contentWrap);
66
+ const structuredContent = Array.isArray(contentProps.content) ? contentProps.content : [];
67
+ const visibleContent = normalizeText(
68
+ contentWrap?.querySelector('.pb-content-item .text')?.textContent
69
+ || contentWrap?.querySelector('.text')?.textContent
70
+ || contentWrap?.textContent
71
+ );
72
+
73
+ return {
74
+ pageMeta: {
75
+ pathname: window.location.pathname || '',
76
+ pn: new URLSearchParams(window.location.search).get('pn') || '',
77
+ },
78
+ mainPost: {
79
+ title: typeof titleProps.title === 'string' && titleProps.title.trim()
80
+ ? titleProps.title.trim()
81
+ : normalizeText(titleNode?.textContent).replace(/-百度贴吧$/, '').trim(),
82
+ author: normalizeText(
83
+ mainUser?.querySelector('.head-name')?.textContent
84
+ || mainUser?.querySelector('.name-info .head-name')?.textContent
85
+ || ''
86
+ ),
87
+ fallbackAuthor: mainUserProps?.userShowInfo?.[0]?.text?.text || '',
88
+ contentText: visibleContent,
89
+ structuredText: extractStructuredText(structuredContent),
90
+ visibleTime: (() => {
91
+ const userText = normalizeText(mainUser?.textContent);
92
+ const match = userText.match(/(刚刚|昨天|前天|\\d+\\s*(?:分钟|小时|天)前|\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2})?|\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2})?)/);
93
+ return match ? match[1].trim() : '';
94
+ })(),
95
+ structuredTime: mainUserProps?.descInfo?.time || 0,
96
+ hasMedia: structuredContent.length > 0 && !extractStructuredText(structuredContent),
97
+ },
98
+ replies: Array.from(document.querySelectorAll('.pb-comment-item')).map((item) => {
99
+ const meta = item.querySelector('.comment-desc-left')?.textContent?.replace(/\\s+/g, ' ').trim() || '';
100
+ return {
101
+ floor: parseFloor(meta),
102
+ author: item.querySelector('.head-name')?.textContent?.trim() || '',
103
+ content: item.querySelector('.comment-content .pb-content-item .text')?.textContent?.replace(/\\s+/g, ' ').trim() || '',
104
+ time: meta,
105
+ };
106
+ }),
107
+ };
108
+ })()
109
+ `;
110
+ }
111
+ cli({
112
+ site: 'tieba',
113
+ name: 'read',
114
+ description: 'Read a tieba thread',
115
+ domain: 'tieba.baidu.com',
116
+ strategy: Strategy.COOKIE,
117
+ browser: true,
118
+ navigateBefore: false,
119
+ args: [
120
+ { name: 'id', positional: true, required: true, type: 'string', help: 'Thread ID' },
121
+ { name: 'page', type: 'int', default: 1, help: 'Page number' },
122
+ { name: 'limit', type: 'int', default: 30, help: 'Number of replies to return' },
123
+ ],
124
+ columns: ['floor', 'author', 'content', 'time'],
125
+ func: async (page, kwargs) => {
126
+ const pageNumber = Math.max(1, Number(kwargs.page || 1));
127
+ // Use the browser's normal settle path so we do not scrape stale DOM from the previous tab state.
128
+ await page.goto(getThreadUrl(kwargs));
129
+ const raw = (await page.evaluate(buildExtractReadEvaluate()) || {});
130
+ assertTiebaReadTargetPage(raw, kwargs);
131
+ const items = buildTiebaReadItems(raw, {
132
+ limit: kwargs.limit,
133
+ includeMainPost: pageNumber === 1,
134
+ });
135
+ if (!items.length) {
136
+ throw new EmptyResultError('tieba read', 'Tieba may have blocked the thread page, or the DOM structure may have changed');
137
+ }
138
+ return items;
139
+ },
140
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { ArgumentError, EmptyResultError } from '../../errors.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import { buildTiebaSearchItems, normalizeTiebaLimit } from './utils.js';
4
+ const MAX_SUPPORTED_PAGE = '1';
5
+ /**
6
+ * Extract search result cards from tieba's current desktop search page.
7
+ */
8
+ function buildExtractSearchResultsEvaluate(limit) {
9
+ return `
10
+ (async () => {
11
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
+ const waitFor = async (predicate, timeoutMs = 5000) => {
13
+ const start = Date.now();
14
+ while (Date.now() - start < timeoutMs) {
15
+ if (predicate()) return true;
16
+ await wait(100);
17
+ }
18
+ return false;
19
+ };
20
+ const getVueProps = (element) => {
21
+ const vue = element && element.__vue__ ? element.__vue__ : null;
22
+ return vue ? (vue._props || vue.$props || {}) : {};
23
+ };
24
+ await waitFor(() => {
25
+ const bodyText = document.body?.innerText || '';
26
+ return Boolean(
27
+ document.querySelector('.threadcardclass.thread-new3.index-feed-cards')
28
+ || document.querySelector('.search-no-result, .search-nodata, .no-result')
29
+ || /百度安全验证|安全验证|请完成验证/.test(bodyText)
30
+ );
31
+ });
32
+ const items = document.querySelectorAll('.threadcardclass.thread-new3.index-feed-cards');
33
+ return Array.from(items).slice(0, ${limit}).map((item) => {
34
+ const forum = item.querySelector('.forum-name-text, .forum-name')?.textContent?.trim() || '';
35
+ const meta = item.querySelector('.user-forum-info')?.textContent?.replace(/\\s+/g, ' ').trim() || '';
36
+ const metaWithoutForum = forum && meta.startsWith(forum)
37
+ ? meta.slice(forum.length).trim()
38
+ : meta;
39
+ const metaMatch = metaWithoutForum.match(/^(.*?)\\s*发布于\\s*(.+)$/);
40
+ const actionBar = item.querySelector('.action-bar-container.search-action-bar');
41
+ const businessInfo = getVueProps(actionBar).businessInfo || {};
42
+ const href = item.querySelector('a[href*="/p/"]')?.href || '';
43
+ const threadId = String(businessInfo.thread_id || '').trim();
44
+ const title = item.querySelector('.title-wrap')?.textContent?.trim()
45
+ || item.querySelector('.title-content-wrap')?.textContent?.trim()
46
+ || '';
47
+ const snippet = item.querySelector('.title-content-wrap')?.textContent?.trim()
48
+ || item.querySelector('.abstract-wrap')?.textContent?.trim()
49
+ || '';
50
+
51
+ return {
52
+ title,
53
+ forum,
54
+ author: metaMatch ? metaMatch[1].trim() : metaWithoutForum,
55
+ time: metaMatch ? metaMatch[2].trim() : '',
56
+ snippet: snippet.substring(0, 200),
57
+ id: threadId,
58
+ url: href || (threadId ? 'https://tieba.baidu.com/p/' + threadId : ''),
59
+ };
60
+ }).filter((item) => item.title);
61
+ })()
62
+ `;
63
+ }
64
+ /**
65
+ * Normalize CLI args into the concrete search page URL.
66
+ */
67
+ function getSearchUrl(kwargs) {
68
+ const keyword = String(kwargs.keyword || '');
69
+ const pageNumber = Number(kwargs.page || 1);
70
+ return `https://tieba.baidu.com/f/search/res?qw=${encodeURIComponent(keyword)}&ie=utf-8&pn=${pageNumber}`;
71
+ }
72
+ /**
73
+ * Tieba's current desktop search UI no longer exposes a reliable browser-page transition.
74
+ */
75
+ function assertSupportedPage(kwargs) {
76
+ const pageNumber = String(kwargs.page || 1);
77
+ if (pageNumber === MAX_SUPPORTED_PAGE)
78
+ return;
79
+ throw new ArgumentError(`tieba search currently only supports --page ${MAX_SUPPORTED_PAGE}`, `Baidu Tieba search no longer exposes stable browser pagination; omit --page or use --page ${MAX_SUPPORTED_PAGE}`);
80
+ }
81
+ cli({
82
+ site: 'tieba',
83
+ name: 'search',
84
+ description: 'Search posts across tieba',
85
+ domain: 'tieba.baidu.com',
86
+ strategy: Strategy.COOKIE,
87
+ browser: true,
88
+ navigateBefore: false,
89
+ args: [
90
+ { name: 'keyword', positional: true, required: true, type: 'string', help: 'Search keyword' },
91
+ // Restrict unsupported pages before the browser session starts.
92
+ { name: 'page', type: 'int', default: 1, choices: ['1'], help: 'Page number (currently only 1 is supported)' },
93
+ { name: 'limit', type: 'int', default: 20, help: 'Number of items to return' },
94
+ ],
95
+ columns: ['rank', 'title', 'forum', 'author', 'time'],
96
+ func: async (page, kwargs) => {
97
+ assertSupportedPage(kwargs);
98
+ const limit = normalizeTiebaLimit(kwargs.limit);
99
+ // Use the default browser settle path so we do not read a stale page.
100
+ await page.goto(getSearchUrl(kwargs));
101
+ const raw = await page.evaluate(buildExtractSearchResultsEvaluate(limit));
102
+ const items = buildTiebaSearchItems(Array.isArray(raw) ? raw : [], limit);
103
+ if (!items.length) {
104
+ throw new EmptyResultError('tieba search', 'Tieba may have blocked the result page, or the DOM structure may have changed');
105
+ }
106
+ return items;
107
+ },
108
+ });