@jackwener/opencli 1.7.4 → 1.7.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 (181) hide show
  1. package/README.md +76 -51
  2. package/README.zh-CN.md +78 -62
  3. package/cli-manifest.json +4558 -2979
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/video.js +61 -0
  8. package/clis/bilibili/video.test.js +81 -0
  9. package/clis/deepseek/ask.js +94 -0
  10. package/clis/deepseek/ask.test.js +73 -0
  11. package/clis/deepseek/history.js +25 -0
  12. package/clis/deepseek/new.js +20 -0
  13. package/clis/deepseek/read.js +22 -0
  14. package/clis/deepseek/status.js +24 -0
  15. package/clis/deepseek/utils.js +291 -0
  16. package/clis/deepseek/utils.test.js +37 -0
  17. package/clis/eastmoney/_secid.js +78 -0
  18. package/clis/eastmoney/announcement.js +52 -0
  19. package/clis/eastmoney/convertible.js +73 -0
  20. package/clis/eastmoney/etf.js +65 -0
  21. package/clis/eastmoney/holders.js +78 -0
  22. package/clis/eastmoney/index-board.js +96 -0
  23. package/clis/eastmoney/kline.js +87 -0
  24. package/clis/eastmoney/kuaixun.js +54 -0
  25. package/clis/eastmoney/longhu.js +67 -0
  26. package/clis/eastmoney/money-flow.js +78 -0
  27. package/clis/eastmoney/northbound.js +57 -0
  28. package/clis/eastmoney/quote.js +107 -0
  29. package/clis/eastmoney/rank.js +94 -0
  30. package/clis/eastmoney/sectors.js +76 -0
  31. package/clis/google-scholar/search.js +58 -0
  32. package/clis/google-scholar/search.test.js +23 -0
  33. package/clis/gov-law/commands.test.js +39 -0
  34. package/clis/gov-law/recent.js +22 -0
  35. package/clis/gov-law/search.js +41 -0
  36. package/clis/gov-law/shared.js +51 -0
  37. package/clis/gov-policy/commands.test.js +27 -0
  38. package/clis/gov-policy/recent.js +47 -0
  39. package/clis/gov-policy/search.js +48 -0
  40. package/clis/jianyu/search.js +139 -3
  41. package/clis/jianyu/search.test.js +25 -0
  42. package/clis/jianyu/shared/procurement-detail.js +15 -0
  43. package/clis/jianyu/shared/procurement-detail.test.js +12 -0
  44. package/clis/nowcoder/companies.js +23 -0
  45. package/clis/nowcoder/creators.js +27 -0
  46. package/clis/nowcoder/detail.js +61 -0
  47. package/clis/nowcoder/experience.js +36 -0
  48. package/clis/nowcoder/hot.js +24 -0
  49. package/clis/nowcoder/jobs.js +21 -0
  50. package/clis/nowcoder/notifications.js +29 -0
  51. package/clis/nowcoder/papers.js +40 -0
  52. package/clis/nowcoder/practice.js +37 -0
  53. package/clis/nowcoder/recommend.js +30 -0
  54. package/clis/nowcoder/referral.js +39 -0
  55. package/clis/nowcoder/salary.js +40 -0
  56. package/clis/nowcoder/search.js +49 -0
  57. package/clis/nowcoder/suggest.js +33 -0
  58. package/clis/nowcoder/topics.js +27 -0
  59. package/clis/nowcoder/trending.js +25 -0
  60. package/clis/twitter/list-add.js +337 -0
  61. package/clis/twitter/list-add.test.js +15 -0
  62. package/clis/twitter/list-remove.js +297 -0
  63. package/clis/twitter/list-remove.test.js +14 -0
  64. package/clis/twitter/list-tweets.js +185 -0
  65. package/clis/twitter/list-tweets.test.js +108 -0
  66. package/clis/twitter/lists.js +134 -47
  67. package/clis/twitter/lists.test.js +105 -38
  68. package/clis/twitter/shared.js +7 -2
  69. package/clis/twitter/tweets.js +218 -0
  70. package/clis/twitter/tweets.test.js +125 -0
  71. package/clis/wanfang/search.js +66 -0
  72. package/clis/wanfang/search.test.js +23 -0
  73. package/clis/web/read.js +1 -1
  74. package/clis/weixin/download.js +3 -2
  75. package/clis/xiaohongshu/publish.js +149 -28
  76. package/clis/xiaohongshu/publish.test.js +319 -6
  77. package/clis/xiaoyuzhou/download.js +8 -4
  78. package/clis/xiaoyuzhou/download.test.js +23 -13
  79. package/clis/xiaoyuzhou/episode.js +9 -4
  80. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  81. package/clis/xiaoyuzhou/podcast.js +9 -4
  82. package/clis/xiaoyuzhou/utils.js +0 -40
  83. package/clis/xiaoyuzhou/utils.test.js +15 -75
  84. package/clis/youtube/channel.js +35 -0
  85. package/clis/zsxq/dynamics.js +1 -1
  86. package/clis/zsxq/utils.js +6 -3
  87. package/clis/zsxq/utils.test.js +31 -0
  88. package/dist/src/browser/base-page.d.ts +14 -4
  89. package/dist/src/browser/base-page.js +35 -25
  90. package/dist/src/browser/bridge.d.ts +1 -0
  91. package/dist/src/browser/bridge.js +1 -1
  92. package/dist/src/browser/cdp.d.ts +1 -0
  93. package/dist/src/browser/cdp.js +13 -4
  94. package/dist/src/browser/compound.d.ts +59 -0
  95. package/dist/src/browser/compound.js +112 -0
  96. package/dist/src/browser/compound.test.js +175 -0
  97. package/dist/src/browser/daemon-client.d.ts +6 -4
  98. package/dist/src/browser/daemon-client.js +6 -1
  99. package/dist/src/browser/daemon-client.test.js +40 -1
  100. package/dist/src/browser/dom-snapshot.d.ts +7 -0
  101. package/dist/src/browser/dom-snapshot.js +83 -5
  102. package/dist/src/browser/dom-snapshot.test.js +65 -0
  103. package/dist/src/browser/extract.d.ts +69 -0
  104. package/dist/src/browser/extract.js +132 -0
  105. package/dist/src/browser/extract.test.js +129 -0
  106. package/dist/src/browser/find.d.ts +76 -0
  107. package/dist/src/browser/find.js +179 -0
  108. package/dist/src/browser/find.test.js +120 -0
  109. package/dist/src/browser/html-tree.d.ts +75 -0
  110. package/dist/src/browser/html-tree.js +112 -0
  111. package/dist/src/browser/html-tree.test.d.ts +1 -0
  112. package/dist/src/browser/html-tree.test.js +181 -0
  113. package/dist/src/browser/network-cache.d.ts +48 -0
  114. package/dist/src/browser/network-cache.js +66 -0
  115. package/dist/src/browser/network-cache.test.d.ts +1 -0
  116. package/dist/src/browser/network-cache.test.js +58 -0
  117. package/dist/src/browser/network-key.d.ts +22 -0
  118. package/dist/src/browser/network-key.js +66 -0
  119. package/dist/src/browser/network-key.test.d.ts +1 -0
  120. package/dist/src/browser/network-key.test.js +49 -0
  121. package/dist/src/browser/page.d.ts +14 -4
  122. package/dist/src/browser/page.js +48 -7
  123. package/dist/src/browser/page.test.js +97 -0
  124. package/dist/src/browser/shape-filter.d.ts +52 -0
  125. package/dist/src/browser/shape-filter.js +101 -0
  126. package/dist/src/browser/shape-filter.test.d.ts +1 -0
  127. package/dist/src/browser/shape-filter.test.js +101 -0
  128. package/dist/src/browser/shape.d.ts +23 -0
  129. package/dist/src/browser/shape.js +95 -0
  130. package/dist/src/browser/shape.test.d.ts +1 -0
  131. package/dist/src/browser/shape.test.js +82 -0
  132. package/dist/src/browser/target-errors.d.ts +14 -1
  133. package/dist/src/browser/target-errors.js +13 -0
  134. package/dist/src/browser/target-errors.test.js +39 -6
  135. package/dist/src/browser/target-resolver.d.ts +57 -10
  136. package/dist/src/browser/target-resolver.js +195 -75
  137. package/dist/src/browser/target-resolver.test.js +80 -5
  138. package/dist/src/cli.js +849 -267
  139. package/dist/src/cli.test.js +961 -90
  140. package/dist/src/commanderAdapter.d.ts +0 -1
  141. package/dist/src/commanderAdapter.js +2 -16
  142. package/dist/src/commanderAdapter.test.js +1 -1
  143. package/dist/src/completion-shared.js +2 -5
  144. package/dist/src/daemon.js +8 -0
  145. package/dist/src/download/article-download.d.ts +1 -0
  146. package/dist/src/download/article-download.js +3 -0
  147. package/dist/src/download/article-download.test.d.ts +1 -0
  148. package/dist/src/download/article-download.test.js +39 -0
  149. package/dist/src/execution.js +7 -2
  150. package/dist/src/execution.test.js +54 -0
  151. package/dist/src/main.js +16 -0
  152. package/dist/src/plugin.d.ts +1 -8
  153. package/dist/src/plugin.js +1 -27
  154. package/dist/src/plugin.test.js +1 -59
  155. package/dist/src/registry.d.ts +1 -0
  156. package/dist/src/registry.js +3 -2
  157. package/dist/src/registry.test.js +22 -0
  158. package/dist/src/types.d.ts +32 -8
  159. package/package.json +1 -1
  160. package/clis/twitter/lists-parser.js +0 -77
  161. package/clis/twitter/lists.d.ts +0 -5
  162. package/dist/src/cascade.d.ts +0 -46
  163. package/dist/src/cascade.js +0 -135
  164. package/dist/src/explore.d.ts +0 -99
  165. package/dist/src/explore.js +0 -402
  166. package/dist/src/generate-verified.d.ts +0 -105
  167. package/dist/src/generate-verified.js +0 -696
  168. package/dist/src/generate-verified.test.js +0 -925
  169. package/dist/src/generate.d.ts +0 -46
  170. package/dist/src/generate.js +0 -117
  171. package/dist/src/record.d.ts +0 -96
  172. package/dist/src/record.js +0 -657
  173. package/dist/src/record.test.js +0 -293
  174. package/dist/src/skill-generate.d.ts +0 -30
  175. package/dist/src/skill-generate.js +0 -75
  176. package/dist/src/skill-generate.test.js +0 -173
  177. package/dist/src/synthesize.d.ts +0 -97
  178. package/dist/src/synthesize.js +0 -208
  179. /package/dist/src/{generate-verified.test.d.ts → browser/compound.test.d.ts} +0 -0
  180. /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
  181. /package/dist/src/{skill-generate.test.d.ts → browser/find.test.d.ts} +0 -0
@@ -2,17 +2,19 @@ import path from 'node:path';
2
2
  import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  import { getRegistry } from '@jackwener/opencli/registry';
4
4
 
5
- const { mockFetchPageProps, mockHttpDownload, mockMkdirSync } = vi.hoisted(() => ({
6
- mockFetchPageProps: vi.fn(),
5
+ const { mockRequestJson, mockLoadCredentials, mockHttpDownload, mockMkdirSync } = vi.hoisted(() => ({
6
+ mockRequestJson: vi.fn(),
7
+ mockLoadCredentials: vi.fn(),
7
8
  mockHttpDownload: vi.fn(),
8
9
  mockMkdirSync: vi.fn(),
9
10
  }));
10
11
 
11
- vi.mock('./utils.js', async () => {
12
- const actual = await vi.importActual('./utils.js');
12
+ vi.mock('./auth.js', async () => {
13
+ const actual = await vi.importActual('./auth.js');
13
14
  return {
14
15
  ...actual,
15
- fetchPageProps: mockFetchPageProps,
16
+ requestXiaoyuzhouJson: mockRequestJson,
17
+ loadXiaoyuzhouCredentials: mockLoadCredentials,
16
18
  };
17
19
  });
18
20
 
@@ -44,14 +46,17 @@ beforeAll(() => {
44
46
 
45
47
  describe('xiaoyuzhou download', () => {
46
48
  beforeEach(() => {
47
- mockFetchPageProps.mockReset();
49
+ mockRequestJson.mockReset();
50
+ mockLoadCredentials.mockReset();
48
51
  mockHttpDownload.mockReset();
49
52
  mockMkdirSync.mockReset();
53
+ mockLoadCredentials.mockReturnValue({});
50
54
  });
51
55
 
52
56
  it('downloads audio from media.source.url into an episode subdirectory', async () => {
53
- mockFetchPageProps.mockResolvedValue({
54
- episode: {
57
+ mockRequestJson.mockResolvedValue({
58
+ credentials: {},
59
+ data: {
55
60
  title: 'Hello World',
56
61
  podcast: { title: 'OpenCLI FM' },
57
62
  media: {
@@ -68,7 +73,10 @@ describe('xiaoyuzhou download', () => {
68
73
  output: '/tmp/xiaoyuzhou-test',
69
74
  });
70
75
 
71
- expect(mockFetchPageProps).toHaveBeenCalledWith('/episode/ep123');
76
+ expect(mockRequestJson).toHaveBeenCalledWith('/v1/episode/get', {
77
+ query: { eid: 'ep123' },
78
+ credentials: {},
79
+ });
72
80
  expect(toPosixPath(mockMkdirSync.mock.calls[0][0])).toBe('/tmp/xiaoyuzhou-test/ep123');
73
81
  expect(mockMkdirSync.mock.calls[0][1]).toEqual({ recursive: true });
74
82
  expect(mockHttpDownload).toHaveBeenCalledWith('https://media.xyzcdn.net/audio/hello-world.mp3?sign=abc', expect.stringContaining('/tmp/xiaoyuzhou-test/ep123/ep123_Hello_World.mp3'), {
@@ -84,8 +92,9 @@ describe('xiaoyuzhou download', () => {
84
92
  });
85
93
 
86
94
  it('preserves non-mp3 extensions from media.source.url', async () => {
87
- mockFetchPageProps.mockResolvedValue({
88
- episode: {
95
+ mockRequestJson.mockResolvedValue({
96
+ credentials: {},
97
+ data: {
89
98
  title: 'Lossless Episode',
90
99
  podcast: { title: 'OpenCLI FM' },
91
100
  media: {
@@ -107,8 +116,9 @@ describe('xiaoyuzhou download', () => {
107
116
  });
108
117
 
109
118
  it('throws when media.source.url is missing', async () => {
110
- mockFetchPageProps.mockResolvedValue({
111
- episode: {
119
+ mockRequestJson.mockResolvedValue({
120
+ credentials: {},
121
+ data: {
112
122
  title: 'No Audio',
113
123
  podcast: { title: 'OpenCLI FM' },
114
124
  media: {},
@@ -1,18 +1,23 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { CliError } from '@jackwener/opencli/errors';
3
- import { fetchPageProps, formatDuration, formatDate } from './utils.js';
3
+ import { loadXiaoyuzhouCredentials, requestXiaoyuzhouJson } from './auth.js';
4
+ import { formatDuration, formatDate } from './utils.js';
4
5
  cli({
5
6
  site: 'xiaoyuzhou',
6
7
  name: 'episode',
7
8
  description: 'View details of a Xiaoyuzhou podcast episode',
8
9
  domain: 'www.xiaoyuzhoufm.com',
9
- strategy: Strategy.PUBLIC,
10
+ strategy: Strategy.LOCAL,
10
11
  browser: false,
11
12
  args: [{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' }],
12
13
  columns: ['title', 'podcast', 'duration', 'plays', 'comments', 'likes', 'date'],
13
14
  func: async (_page, args) => {
14
- const pageProps = await fetchPageProps(`/episode/${args.id}`);
15
- const ep = pageProps.episode;
15
+ const credentials = loadXiaoyuzhouCredentials();
16
+ const response = await requestXiaoyuzhouJson('/v1/episode/get', {
17
+ query: { eid: args.id },
18
+ credentials,
19
+ });
20
+ const ep = response.data;
16
21
  if (!ep)
17
22
  throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the ID');
18
23
  return [{
@@ -1,30 +1,34 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { CliError } from '@jackwener/opencli/errors';
3
- import { fetchPageProps, formatDuration, formatDate } from './utils.js';
3
+ import { loadXiaoyuzhouCredentials, requestXiaoyuzhouJson } from './auth.js';
4
+ import { formatDuration, formatDate } from './utils.js';
4
5
  cli({
5
6
  site: 'xiaoyuzhou',
6
7
  name: 'podcast-episodes',
7
- description: 'List recent episodes of a Xiaoyuzhou podcast (up to 15, SSR limit)',
8
+ description: 'List episodes of a Xiaoyuzhou podcast',
8
9
  domain: 'www.xiaoyuzhoufm.com',
9
- strategy: Strategy.PUBLIC,
10
+ strategy: Strategy.LOCAL,
10
11
  browser: false,
11
12
  args: [
12
13
  { name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' },
13
- { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show (up to 15, SSR limit)' },
14
+ { name: 'limit', type: 'int', default: 20, help: 'Max episodes to show' },
14
15
  ],
15
16
  columns: ['eid', 'title', 'duration', 'plays', 'date'],
16
17
  func: async (_page, args) => {
17
- const pageProps = await fetchPageProps(`/podcast/${args.id}`);
18
- const podcast = pageProps.podcast;
19
- if (!podcast)
20
- throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID');
21
- const allEpisodes = podcast.episodes ?? [];
22
18
  const requestedLimit = Number(args.limit);
23
19
  if (!Number.isInteger(requestedLimit) || requestedLimit < 1) {
24
20
  throw new CliError('INVALID_ARGUMENT', 'limit must be a positive integer', 'Example: --limit 5');
25
21
  }
26
- const limit = Math.min(requestedLimit, allEpisodes.length);
27
- const episodes = allEpisodes.slice(0, limit);
22
+ const credentials = loadXiaoyuzhouCredentials();
23
+ const response = await requestXiaoyuzhouJson('/v1/podcast/listEpisode', {
24
+ method: 'POST',
25
+ body: { pid: args.id, limit: requestedLimit },
26
+ credentials,
27
+ });
28
+ const episodes = response.data ?? [];
29
+ if (!Array.isArray(episodes)) {
30
+ throw new CliError('PARSE_ERROR', 'Unexpected API response format', 'Expected an array of episodes');
31
+ }
28
32
  return episodes.map((ep) => ({
29
33
  eid: ep.eid,
30
34
  title: ep.title,
@@ -1,18 +1,23 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { CliError } from '@jackwener/opencli/errors';
3
- import { fetchPageProps, formatDate } from './utils.js';
3
+ import { loadXiaoyuzhouCredentials, requestXiaoyuzhouJson } from './auth.js';
4
+ import { formatDate } from './utils.js';
4
5
  cli({
5
6
  site: 'xiaoyuzhou',
6
7
  name: 'podcast',
7
8
  description: 'View a Xiaoyuzhou podcast profile',
8
9
  domain: 'www.xiaoyuzhoufm.com',
9
- strategy: Strategy.PUBLIC,
10
+ strategy: Strategy.LOCAL,
10
11
  browser: false,
11
12
  args: [{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' }],
12
13
  columns: ['title', 'author', 'description', 'subscribers', 'episodes', 'updated'],
13
14
  func: async (_page, args) => {
14
- const pageProps = await fetchPageProps(`/podcast/${args.id}`);
15
- const p = pageProps.podcast;
15
+ const credentials = loadXiaoyuzhouCredentials();
16
+ const response = await requestXiaoyuzhouJson('/v1/podcast/get', {
17
+ query: { pid: args.id },
18
+ credentials,
19
+ });
20
+ const p = response.data;
16
21
  if (!p)
17
22
  throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID');
18
23
  return [{
@@ -1,43 +1,3 @@
1
- /**
2
- * Shared Xiaoyuzhou utilities — page data extraction and formatting.
3
- *
4
- * Xiaoyuzhou (小宇宙) is a Next.js app that embeds full page data in
5
- * <script id="__NEXT_DATA__">. We fetch the HTML and extract that JSON
6
- * instead of using their authenticated API.
7
- */
8
- import { CliError } from '@jackwener/opencli/errors';
9
- /**
10
- * Fetch a Xiaoyuzhou page and extract __NEXT_DATA__.props.pageProps.
11
- * @param path - URL path, e.g. '/podcast/xxx' or '/episode/xxx'
12
- */
13
- export async function fetchPageProps(path) {
14
- const url = `https://www.xiaoyuzhoufm.com${path}`;
15
- // Node.js fetch sends UA "node" which gets blocked; use a browser-like UA
16
- const resp = await fetch(url, {
17
- headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli)' },
18
- });
19
- if (!resp.ok) {
20
- throw new CliError('FETCH_ERROR', `HTTP ${resp.status} for ${path}`, 'Please check the ID — you can find it in xiaoyuzhoufm.com URLs');
21
- }
22
- const html = await resp.text();
23
- // [\s\S]*? for multiline safety (JSON may span lines)
24
- const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
25
- if (!match) {
26
- throw new CliError('PARSE_ERROR', 'Failed to extract __NEXT_DATA__', 'Page structure may have changed');
27
- }
28
- let parsed;
29
- try {
30
- parsed = JSON.parse(match[1]);
31
- }
32
- catch {
33
- throw new CliError('PARSE_ERROR', 'Malformed __NEXT_DATA__ JSON', 'Page structure may have changed');
34
- }
35
- const pageProps = parsed.props?.pageProps;
36
- if (!pageProps || Object.keys(pageProps).length === 0) {
37
- throw new CliError('NOT_FOUND', 'Resource not found', 'Please check the ID — you can find it in xiaoyuzhoufm.com URLs');
38
- }
39
- return pageProps;
40
- }
41
1
  /** Format seconds to mm:ss (e.g. 3890 → "64:50"). Returns '-' for invalid input. */
42
2
  export function formatDuration(seconds) {
43
3
  if (!Number.isFinite(seconds) || seconds < 0)
@@ -1,5 +1,5 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { formatDuration, formatDate, fetchPageProps } from './utils.js';
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatDuration, formatDate } from './utils.js';
3
3
  describe('formatDuration', () => {
4
4
  it('formats typical duration', () => {
5
5
  expect(formatDuration(3890)).toBe('64:50');
@@ -8,13 +8,13 @@ describe('formatDuration', () => {
8
8
  expect(formatDuration(0)).toBe('0:00');
9
9
  });
10
10
  it('pads single-digit seconds', () => {
11
- expect(formatDuration(65)).toBe('1:05');
11
+ expect(formatDuration(62)).toBe('1:02');
12
12
  });
13
- it('formats exact minutes', () => {
14
- expect(formatDuration(3600)).toBe('60:00');
13
+ it('handles exact minutes', () => {
14
+ expect(formatDuration(120)).toBe('2:00');
15
15
  });
16
- it('rounds floating-point seconds', () => {
17
- expect(formatDuration(3890.7)).toBe('64:51');
16
+ it('rounds fractional seconds', () => {
17
+ expect(formatDuration(65.7)).toBe('1:06');
18
18
  });
19
19
  it('returns dash for NaN', () => {
20
20
  expect(formatDuration(NaN)).toBe('-');
@@ -22,78 +22,18 @@ describe('formatDuration', () => {
22
22
  it('returns dash for negative', () => {
23
23
  expect(formatDuration(-1)).toBe('-');
24
24
  });
25
+ it('returns dash for Infinity', () => {
26
+ expect(formatDuration(Infinity)).toBe('-');
27
+ });
25
28
  });
26
29
  describe('formatDate', () => {
27
- it('extracts YYYY-MM-DD from ISO string', () => {
28
- expect(formatDate('2026-03-13T11:00:06.686Z')).toBe('2026-03-13');
29
- });
30
- it('handles date-only string', () => {
31
- expect(formatDate('2025-01-01')).toBe('2025-01-01');
30
+ it('slices ISO string to date', () => {
31
+ expect(formatDate('2026-03-15T12:00:00Z')).toBe('2026-03-15');
32
32
  });
33
- it('returns dash for undefined/empty', () => {
33
+ it('returns dash for empty string', () => {
34
34
  expect(formatDate('')).toBe('-');
35
- expect(formatDate(undefined)).toBe('-');
36
- });
37
- });
38
- describe('fetchPageProps', () => {
39
- beforeEach(() => {
40
- vi.restoreAllMocks();
41
- });
42
- it('extracts pageProps from valid HTML', async () => {
43
- const mockHtml = `<html><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"podcast":{"title":"Test"}}}}</script></html>`;
44
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
45
- ok: true,
46
- text: () => Promise.resolve(mockHtml),
47
- }));
48
- const result = await fetchPageProps('/podcast/abc123');
49
- expect(result).toEqual({ podcast: { title: 'Test' } });
50
35
  });
51
- it('throws on HTTP error', async () => {
52
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
53
- ok: false,
54
- status: 404,
55
- text: () => Promise.resolve('Not Found'),
56
- }));
57
- await expect(fetchPageProps('/podcast/invalid')).rejects.toThrow('HTTP 404');
58
- });
59
- it('throws when __NEXT_DATA__ is missing', async () => {
60
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
61
- ok: true,
62
- text: () => Promise.resolve('<html><body>No data here</body></html>'),
63
- }));
64
- await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Failed to extract');
65
- });
66
- it('throws when pageProps is empty', async () => {
67
- const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}}}</script>`;
68
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
69
- ok: true,
70
- text: () => Promise.resolve(mockHtml),
71
- }));
72
- await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Resource not found');
73
- });
74
- it('throws on malformed JSON in __NEXT_DATA__', async () => {
75
- const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{broken json</script>`;
76
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
77
- ok: true,
78
- text: () => Promise.resolve(mockHtml),
79
- }));
80
- await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Malformed __NEXT_DATA__');
81
- });
82
- it('handles multiline JSON in __NEXT_DATA__', async () => {
83
- const mockHtml = `<script id="__NEXT_DATA__" type="application/json">
84
- {
85
- "props": {
86
- "pageProps": {
87
- "episode": {"title": "Multiline Test"}
88
- }
89
- }
90
- }
91
- </script>`;
92
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
93
- ok: true,
94
- text: () => Promise.resolve(mockHtml),
95
- }));
96
- const result = await fetchPageProps('/episode/abc');
97
- expect(result).toEqual({ episode: { title: 'Multiline Test' } });
36
+ it('returns dash for undefined', () => {
37
+ expect(formatDate(undefined)).toBe('-');
98
38
  });
99
39
  });
@@ -115,6 +115,41 @@ cli({
115
115
  }
116
116
  }
117
117
 
118
+ // If Home tab has no videos, try Videos tab
119
+ if (recentVideos.length === 0) {
120
+ const videosTab = tabs.find(t => {
121
+ const tab = t.tabRenderer;
122
+ const url = tab?.endpoint?.commandMetadata?.webCommandMetadata?.url || '';
123
+ return tab?.tabIdentifier === 'VIDEOS'
124
+ || url.endsWith('/videos')
125
+ || tab?.title === 'Videos';
126
+ });
127
+ const videosTabParams = videosTab?.tabRenderer?.endpoint?.browseEndpoint?.params;
128
+ if (videosTabParams) {
129
+ const videosResp = await fetch('/youtubei/v1/browse?key=' + apiKey + '&prettyPrint=false', {
130
+ method: 'POST', credentials: 'include',
131
+ headers: {'Content-Type': 'application/json'},
132
+ body: JSON.stringify({context, browseId, params: videosTabParams})
133
+ });
134
+ if (videosResp.ok) {
135
+ const videosData = await videosResp.json();
136
+ const richGrid = videosData.contents?.twoColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.richGridRenderer?.contents || [];
137
+ for (const item of richGrid) {
138
+ if (recentVideos.length >= limit) break;
139
+ const v = item.richItemRenderer?.content?.videoRenderer;
140
+ if (v) {
141
+ recentVideos.push({
142
+ title: v.title?.runs?.[0]?.text || '',
143
+ duration: v.lengthText?.simpleText || '',
144
+ views: (v.shortViewCountText?.simpleText || '') + (v.publishedTimeText?.simpleText ? ' | ' + v.publishedTimeText.simpleText : ''),
145
+ url: 'https://www.youtube.com/watch?v=' + v.videoId,
146
+ });
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
118
153
  return {
119
154
  name: metadata.title || '',
120
155
  channelId: metadata.externalId || browseId,
@@ -37,7 +37,7 @@ cli({
37
37
  time: d.create_time || topic.create_time || '',
38
38
  group: topic.group?.name || '',
39
39
  author: getTopicAuthor(topic),
40
- title: getTopicText(topic).slice(0, 120),
40
+ title: getTopicText(topic),
41
41
  comments: topic.comments_count ?? 0,
42
42
  likes: topic.likes_count ?? 0,
43
43
  url: getTopicUrl(topic.topic_id),
@@ -186,8 +186,11 @@ export function getTopicAuthor(topic) {
186
186
  '');
187
187
  }
188
188
  export function getTopicText(topic) {
189
+ const title = (topic.title || '').replace(/\s+/g, ' ').trim();
190
+ return title || getTopicContent(topic);
191
+ }
192
+ export function getTopicContent(topic) {
189
193
  const primary = [
190
- topic.title,
191
194
  topic.talk?.text,
192
195
  topic.question?.text,
193
196
  topic.answer?.text,
@@ -218,8 +221,8 @@ export function toTopicRow(topic) {
218
221
  type: topic.type || '',
219
222
  group: topic.group?.name || '',
220
223
  author: getTopicAuthor(topic),
221
- title: getTopicText(topic).slice(0, 120),
222
- content: getTopicText(topic),
224
+ title: getTopicText(topic),
225
+ content: getTopicContent(topic),
223
226
  comments: topic.comments_count ?? comments.length ?? 0,
224
227
  likes: topic.likes_count ?? 0,
225
228
  readers: topic.readers_count ?? topic.reading_count ?? 0,
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getTopicText, toTopicRow } from './utils.js';
3
+
4
+ describe('zsxq utils', () => {
5
+ it('keeps title and content separate when both fields exist', () => {
6
+ const topic = {
7
+ topic_id: '123',
8
+ title: 'A full title that should not be truncated',
9
+ talk: { text: 'This is the full body text.' },
10
+ };
11
+
12
+ expect(getTopicText(topic)).toBe('A full title that should not be truncated');
13
+ expect(toTopicRow(topic)).toMatchObject({
14
+ title: 'A full title that should not be truncated',
15
+ content: 'This is the full body text.',
16
+ });
17
+ });
18
+
19
+ it('falls back to body text for title when explicit title is absent', () => {
20
+ const topic = {
21
+ topic_id: '456',
22
+ talk: { text: 'Body-only topic text should still appear as the title preview.' },
23
+ };
24
+
25
+ expect(getTopicText(topic)).toBe('Body-only topic text should still appear as the title preview.');
26
+ expect(toTopicRow(topic)).toMatchObject({
27
+ title: 'Body-only topic text should still appear as the title preview.',
28
+ content: 'Body-only topic text should still appear as the title preview.',
29
+ });
30
+ });
31
+ });
@@ -9,6 +9,16 @@
9
9
  * getCookies, screenshot, tabs, etc.
10
10
  */
11
11
  import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
12
+ import { type ResolveOptions, type TargetMatchLevel } from './target-resolver.js';
13
+ export interface ResolveSuccess {
14
+ matches_n: number;
15
+ /**
16
+ * Cascading stale-ref tier the resolver traversed. Callers surface this to
17
+ * agents so `stable` / `reidentified` hits are visibly distinct from a
18
+ * clean `exact` match — the page changed, the action still succeeded.
19
+ */
20
+ match_level: TargetMatchLevel;
21
+ }
12
22
  export declare abstract class BasePage implements IPage {
13
23
  protected _lastUrl: string | null;
14
24
  /** Cached previous snapshot hashes for incremental diff marking */
@@ -33,13 +43,13 @@ export declare abstract class BasePage implements IPage {
33
43
  }): Promise<BrowserCookie[]>;
34
44
  abstract screenshot(options?: ScreenshotOptions): Promise<string>;
35
45
  abstract tabs(): Promise<unknown[]>;
36
- abstract selectTab(index: number): Promise<void>;
37
- click(ref: string): Promise<void>;
46
+ abstract selectTab(target: number | string): Promise<void>;
47
+ click(ref: string, opts?: ResolveOptions): Promise<ResolveSuccess>;
38
48
  /** Override in subclasses with CDP native click support */
39
49
  protected tryNativeClick(_x: number, _y: number): Promise<boolean>;
40
- typeText(ref: string, text: string): Promise<void>;
50
+ typeText(ref: string, text: string, opts?: ResolveOptions): Promise<ResolveSuccess>;
41
51
  pressKey(key: string): Promise<void>;
42
- scrollTo(ref: string): Promise<unknown>;
52
+ scrollTo(ref: string, opts?: ResolveOptions): Promise<unknown>;
43
53
  getFormState(): Promise<Record<string, unknown>>;
44
54
  scroll(direction?: string, amount?: number): Promise<void>;
45
55
  autoScroll(options?: {
@@ -10,8 +10,26 @@
10
10
  */
11
11
  import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
12
12
  import { pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
13
- import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs } from './target-resolver.js';
13
+ import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs, } from './target-resolver.js';
14
14
  import { TargetError } from './target-errors.js';
15
+ /**
16
+ * Execute `resolveTargetJs` once, throw structured `TargetError` on failure.
17
+ * Single helper so click/typeText/scrollTo share one resolution pathway,
18
+ * which is what the selector-first contract promises agents.
19
+ */
20
+ async function runResolve(page, ref, opts = {}) {
21
+ const resolution = (await page.evaluate(resolveTargetJs(ref, opts)));
22
+ if (!resolution.ok) {
23
+ throw new TargetError({
24
+ code: resolution.code,
25
+ message: resolution.message,
26
+ hint: resolution.hint,
27
+ candidates: resolution.candidates,
28
+ matches_n: resolution.matches_n,
29
+ });
30
+ }
31
+ return { matches_n: resolution.matches_n, match_level: resolution.match_level };
32
+ }
15
33
  import { formatSnapshot } from '../snapshotFormatter.js';
16
34
  export class BasePage {
17
35
  _lastUrl = null;
@@ -37,25 +55,20 @@ export class BasePage {
37
55
  return this.evaluate(`${declarations}\n${js}`);
38
56
  }
39
57
  // ── Shared DOM helper implementations ──
40
- async click(ref) {
58
+ async click(ref, opts = {}) {
41
59
  // Phase 1: Resolve target with fingerprint verification
42
- const resolution = await this.evaluate(resolveTargetJs(ref));
43
- if (!resolution.ok) {
44
- throw new TargetError(resolution);
45
- }
60
+ const resolved = await runResolve(this, ref, opts);
46
61
  // Phase 2: Execute click on resolved element
47
62
  const result = await this.evaluate(clickResolvedJs());
48
- // Backwards compat: old format returned 'clicked' string
49
63
  if (typeof result === 'string' || result == null)
50
- return;
51
- // JS click succeeded
64
+ return resolved;
52
65
  if (result.status === 'clicked')
53
- return;
66
+ return resolved;
54
67
  // JS click failed — try CDP native click if coordinates available
55
68
  if (result.x != null && result.y != null) {
56
69
  const success = await this.tryNativeClick(result.x, result.y);
57
70
  if (success)
58
- return;
71
+ return resolved;
59
72
  }
60
73
  throw new Error(`Click failed: ${result.error ?? 'JS click and CDP fallback both failed'}`);
61
74
  }
@@ -63,26 +76,23 @@ export class BasePage {
63
76
  async tryNativeClick(_x, _y) {
64
77
  return false;
65
78
  }
66
- async typeText(ref, text) {
67
- // Phase 1: Resolve target with fingerprint verification
68
- const resolution = await this.evaluate(resolveTargetJs(ref));
69
- if (!resolution.ok) {
70
- throw new TargetError(resolution);
71
- }
72
- // Phase 2: Execute type on resolved element
79
+ async typeText(ref, text, opts = {}) {
80
+ const resolved = await runResolve(this, ref, opts);
73
81
  await this.evaluate(typeResolvedJs(text));
82
+ return resolved;
74
83
  }
75
84
  async pressKey(key) {
76
85
  await this.evaluate(pressKeyJs(key));
77
86
  }
78
- async scrollTo(ref) {
79
- // Phase 1: Resolve target with fingerprint verification
80
- const resolution = await this.evaluate(resolveTargetJs(ref));
81
- if (!resolution.ok) {
82
- throw new TargetError(resolution);
87
+ async scrollTo(ref, opts = {}) {
88
+ const resolved = await runResolve(this, ref, opts);
89
+ const result = (await this.evaluate(scrollResolvedJs()));
90
+ // Fold match_level into the scroll payload so the user-facing envelope
91
+ // carries it the same way click / type do.
92
+ if (result && typeof result === 'object') {
93
+ return { ...result, matches_n: resolved.matches_n, match_level: resolved.match_level };
83
94
  }
84
- // Phase 2: Scroll to resolved element
85
- return this.evaluate(scrollResolvedJs());
95
+ return { matches_n: resolved.matches_n, match_level: resolved.match_level };
86
96
  }
87
97
  async getFormState() {
88
98
  return (await this.evaluate(getFormStateJs()));
@@ -15,6 +15,7 @@ export declare class BrowserBridge implements IBrowserFactory {
15
15
  connect(opts?: {
16
16
  timeout?: number;
17
17
  workspace?: string;
18
+ idleTimeout?: number;
18
19
  }): Promise<IPage>;
19
20
  close(): Promise<void>;
20
21
  private _ensureDaemon;
@@ -33,7 +33,7 @@ export class BrowserBridge {
33
33
  this._state = 'connecting';
34
34
  try {
35
35
  await this._ensureDaemon(opts.timeout);
36
- this._page = new Page(opts.workspace);
36
+ this._page = new Page(opts.workspace, opts.idleTimeout);
37
37
  this._state = 'connected';
38
38
  return this._page;
39
39
  }
@@ -15,6 +15,7 @@ export interface CDPTarget {
15
15
  title?: string;
16
16
  webSocketDebuggerUrl?: string;
17
17
  }
18
+ export declare const CDP_RESPONSE_BODY_CAPTURE_LIMIT: number;
18
19
  export declare class CDPBridge implements IBrowserFactory {
19
20
  private _ws;
20
21
  private _idCounter;
@@ -17,6 +17,12 @@ import { isRecord, saveBase64ToFile } from '../utils.js';
17
17
  import { getAllElectronApps } from '../electron-apps.js';
18
18
  import { BasePage } from './base-page.js';
19
19
  const CDP_SEND_TIMEOUT = 30_000;
20
+ // Memory guard for in-process capture. The 4k cap we used to apply everywhere
21
+ // silently truncated JSON so `JSON.parse` failed or gave partial objects — the
22
+ // primary agent-facing bug. Now we keep the full body up to a large cap and
23
+ // surface `responseBodyFullSize` + `responseBodyTruncated` so downstream layers
24
+ // can tell the agent what happened instead of lying about the payload.
25
+ export const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024;
20
26
  export class CDPBridge {
21
27
  _ws = null;
22
28
  _idCounter = 0;
@@ -240,9 +246,12 @@ class CDPPage extends BasePage {
240
246
  const bodyFetch = this.bridge.send('Network.getResponseBody', { requestId: p.requestId }).then((result) => {
241
247
  const r = result;
242
248
  if (typeof r?.body === 'string') {
243
- this._networkEntries[idx].responsePreview = r.base64Encoded
244
- ? `base64:${r.body.slice(0, 4000)}`
245
- : r.body.slice(0, 4000);
249
+ const fullSize = r.body.length;
250
+ const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT;
251
+ const body = truncated ? r.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : r.body;
252
+ this._networkEntries[idx].responsePreview = r.base64Encoded ? `base64:${body}` : body;
253
+ this._networkEntries[idx].responseBodyFullSize = fullSize;
254
+ this._networkEntries[idx].responseBodyTruncated = truncated;
246
255
  }
247
256
  }).catch(() => {
248
257
  // Body unavailable for some requests (e.g. uploads) — non-fatal
@@ -296,7 +305,7 @@ class CDPPage extends BasePage {
296
305
  async tabs() {
297
306
  return [];
298
307
  }
299
- async selectTab(_index) {
308
+ async selectTab(_target) {
300
309
  // Not supported in direct CDP mode
301
310
  }
302
311
  }