@jackwener/opencli 1.7.4 → 1.7.5

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 (126) hide show
  1. package/README.md +71 -49
  2. package/README.zh-CN.md +73 -60
  3. package/cli-manifest.json +3261 -1758
  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/deepseek/ask.js +74 -0
  8. package/clis/deepseek/history.js +25 -0
  9. package/clis/deepseek/new.js +20 -0
  10. package/clis/deepseek/read.js +22 -0
  11. package/clis/deepseek/status.js +24 -0
  12. package/clis/deepseek/utils.js +208 -0
  13. package/clis/eastmoney/_secid.js +78 -0
  14. package/clis/eastmoney/announcement.js +52 -0
  15. package/clis/eastmoney/convertible.js +73 -0
  16. package/clis/eastmoney/etf.js +65 -0
  17. package/clis/eastmoney/holders.js +78 -0
  18. package/clis/eastmoney/index-board.js +96 -0
  19. package/clis/eastmoney/kline.js +87 -0
  20. package/clis/eastmoney/kuaixun.js +54 -0
  21. package/clis/eastmoney/longhu.js +67 -0
  22. package/clis/eastmoney/money-flow.js +78 -0
  23. package/clis/eastmoney/northbound.js +57 -0
  24. package/clis/eastmoney/quote.js +107 -0
  25. package/clis/eastmoney/rank.js +94 -0
  26. package/clis/eastmoney/sectors.js +76 -0
  27. package/clis/google-scholar/search.js +58 -0
  28. package/clis/google-scholar/search.test.js +23 -0
  29. package/clis/gov-law/commands.test.js +39 -0
  30. package/clis/gov-law/recent.js +22 -0
  31. package/clis/gov-law/search.js +41 -0
  32. package/clis/gov-law/shared.js +51 -0
  33. package/clis/gov-policy/commands.test.js +27 -0
  34. package/clis/gov-policy/recent.js +47 -0
  35. package/clis/gov-policy/search.js +48 -0
  36. package/clis/nowcoder/companies.js +23 -0
  37. package/clis/nowcoder/creators.js +27 -0
  38. package/clis/nowcoder/detail.js +61 -0
  39. package/clis/nowcoder/experience.js +36 -0
  40. package/clis/nowcoder/hot.js +24 -0
  41. package/clis/nowcoder/jobs.js +21 -0
  42. package/clis/nowcoder/notifications.js +29 -0
  43. package/clis/nowcoder/papers.js +40 -0
  44. package/clis/nowcoder/practice.js +37 -0
  45. package/clis/nowcoder/recommend.js +30 -0
  46. package/clis/nowcoder/referral.js +39 -0
  47. package/clis/nowcoder/salary.js +40 -0
  48. package/clis/nowcoder/search.js +49 -0
  49. package/clis/nowcoder/suggest.js +33 -0
  50. package/clis/nowcoder/topics.js +27 -0
  51. package/clis/nowcoder/trending.js +25 -0
  52. package/clis/twitter/list-add.js +337 -0
  53. package/clis/twitter/list-add.test.js +15 -0
  54. package/clis/twitter/list-remove.js +297 -0
  55. package/clis/twitter/list-remove.test.js +14 -0
  56. package/clis/twitter/list-tweets.js +185 -0
  57. package/clis/twitter/list-tweets.test.js +108 -0
  58. package/clis/twitter/lists.js +134 -47
  59. package/clis/twitter/lists.test.js +105 -38
  60. package/clis/wanfang/search.js +66 -0
  61. package/clis/wanfang/search.test.js +23 -0
  62. package/clis/web/read.js +1 -1
  63. package/clis/weixin/download.js +3 -2
  64. package/clis/xiaohongshu/publish.js +149 -28
  65. package/clis/xiaohongshu/publish.test.js +319 -6
  66. package/clis/xiaoyuzhou/download.js +8 -4
  67. package/clis/xiaoyuzhou/download.test.js +23 -13
  68. package/clis/xiaoyuzhou/episode.js +9 -4
  69. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  70. package/clis/xiaoyuzhou/podcast.js +9 -4
  71. package/clis/xiaoyuzhou/utils.js +0 -40
  72. package/clis/xiaoyuzhou/utils.test.js +15 -75
  73. package/clis/zsxq/dynamics.js +1 -1
  74. package/clis/zsxq/utils.js +6 -3
  75. package/clis/zsxq/utils.test.js +31 -0
  76. package/dist/src/browser/base-page.d.ts +1 -1
  77. package/dist/src/browser/bridge.d.ts +1 -0
  78. package/dist/src/browser/bridge.js +1 -1
  79. package/dist/src/browser/cdp.js +1 -1
  80. package/dist/src/browser/daemon-client.d.ts +6 -4
  81. package/dist/src/browser/daemon-client.js +6 -1
  82. package/dist/src/browser/daemon-client.test.js +40 -1
  83. package/dist/src/browser/dom-snapshot.js +7 -2
  84. package/dist/src/browser/page.d.ts +14 -4
  85. package/dist/src/browser/page.js +48 -7
  86. package/dist/src/browser/page.test.js +97 -0
  87. package/dist/src/cli.js +227 -150
  88. package/dist/src/cli.test.js +167 -90
  89. package/dist/src/commanderAdapter.d.ts +0 -1
  90. package/dist/src/commanderAdapter.js +2 -16
  91. package/dist/src/commanderAdapter.test.js +1 -1
  92. package/dist/src/completion-shared.js +2 -5
  93. package/dist/src/daemon.js +8 -0
  94. package/dist/src/download/article-download.d.ts +1 -0
  95. package/dist/src/download/article-download.js +3 -0
  96. package/dist/src/download/article-download.test.js +39 -0
  97. package/dist/src/plugin.d.ts +1 -8
  98. package/dist/src/plugin.js +1 -27
  99. package/dist/src/plugin.test.js +1 -59
  100. package/dist/src/registry.d.ts +1 -0
  101. package/dist/src/registry.js +3 -2
  102. package/dist/src/registry.test.js +22 -0
  103. package/dist/src/types.d.ts +14 -5
  104. package/package.json +1 -1
  105. package/clis/twitter/lists-parser.js +0 -77
  106. package/clis/twitter/lists.d.ts +0 -5
  107. package/dist/src/cascade.d.ts +0 -46
  108. package/dist/src/cascade.js +0 -135
  109. package/dist/src/explore.d.ts +0 -99
  110. package/dist/src/explore.js +0 -402
  111. package/dist/src/generate-verified.d.ts +0 -105
  112. package/dist/src/generate-verified.js +0 -696
  113. package/dist/src/generate-verified.test.js +0 -925
  114. package/dist/src/generate.d.ts +0 -46
  115. package/dist/src/generate.js +0 -117
  116. package/dist/src/record.d.ts +0 -96
  117. package/dist/src/record.js +0 -657
  118. package/dist/src/record.test.d.ts +0 -1
  119. package/dist/src/record.test.js +0 -293
  120. package/dist/src/skill-generate.d.ts +0 -30
  121. package/dist/src/skill-generate.js +0 -75
  122. package/dist/src/skill-generate.test.d.ts +0 -1
  123. package/dist/src/skill-generate.test.js +0 -173
  124. package/dist/src/synthesize.d.ts +0 -97
  125. package/dist/src/synthesize.js +0 -208
  126. /package/dist/src/{generate-verified.test.d.ts → download/article-download.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
  });
@@ -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
+ });
@@ -33,7 +33,7 @@ export declare abstract class BasePage implements IPage {
33
33
  }): Promise<BrowserCookie[]>;
34
34
  abstract screenshot(options?: ScreenshotOptions): Promise<string>;
35
35
  abstract tabs(): Promise<unknown[]>;
36
- abstract selectTab(index: number): Promise<void>;
36
+ abstract selectTab(target: number | string): Promise<void>;
37
37
  click(ref: string): Promise<void>;
38
38
  /** Override in subclasses with CDP native click support */
39
39
  protected tryNativeClick(_x: number, _y: number): Promise<boolean>;
@@ -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
  }
@@ -296,7 +296,7 @@ class CDPPage extends BasePage {
296
296
  async tabs() {
297
297
  return [];
298
298
  }
299
- async selectTab(_index) {
299
+ async selectTab(_target) {
300
300
  // Not supported in direct CDP mode
301
301
  }
302
302
  }
@@ -6,11 +6,9 @@
6
6
  import type { BrowserSessionInfo } from '../types.js';
7
7
  export interface DaemonCommand {
8
8
  id: string;
9
- action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp';
10
- /** Target page identity (targetId). Cross-layer contract preferred over tabId. */
9
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp' | 'frames';
10
+ /** Target page identity (targetId). Cross-layer contract with the extension. */
11
11
  page?: string;
12
- /** @deprecated Legacy tab ID — use `page` (targetId) instead. */
13
- tabId?: number;
14
12
  code?: string;
15
13
  workspace?: string;
16
14
  url?: string;
@@ -34,6 +32,10 @@ export interface DaemonCommand {
34
32
  cdpParams?: Record<string, unknown>;
35
33
  /** When true, automation windows are created in the foreground */
36
34
  windowFocused?: boolean;
35
+ /** Custom idle timeout in seconds for this workspace session. Overrides the default. */
36
+ idleTimeout?: number;
37
+ /** Frame index for cross-frame operations (0-based, from 'frames' action) */
38
+ frameIndex?: number;
37
39
  }
38
40
  export interface DaemonResult {
39
41
  id: string;
@@ -11,7 +11,7 @@ const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
11
11
  const OPENCLI_HEADERS = { 'X-OpenCLI': '1' };
12
12
  let _idCounter = 0;
13
13
  function generateId() {
14
- return `cmd_${Date.now()}_${++_idCounter}`;
14
+ return `cmd_${process.pid}_${Date.now()}_${++_idCounter}`;
15
15
  }
16
16
  async function requestDaemon(pathname, init) {
17
17
  const { timeout = 2000, headers, ...rest } = init ?? {};
@@ -85,6 +85,11 @@ async function sendCommandRaw(action, params) {
85
85
  });
86
86
  const result = (await res.json());
87
87
  if (!result.ok) {
88
+ const isDuplicateCommandId = res.status === 409
89
+ || (result.error ?? '').includes('Duplicate command id');
90
+ if (isDuplicateCommandId && attempt < maxRetries) {
91
+ continue;
92
+ }
88
93
  const advice = classifyBrowserError(new Error(result.error ?? ''));
89
94
  if (advice.retryable && attempt < maxRetries) {
90
95
  await sleep(advice.delayMs);
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, } from './daemon-client.js';
2
+ import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, sendCommand, } from './daemon-client.js';
3
3
  describe('daemon-client', () => {
4
4
  beforeEach(() => {
5
5
  vi.stubGlobal('fetch', vi.fn());
@@ -78,4 +78,43 @@ describe('daemon-client', () => {
78
78
  });
79
79
  await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status });
80
80
  });
81
+ it('sendCommand includes the current pid in generated command ids', async () => {
82
+ vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
83
+ vi.mocked(fetch).mockResolvedValue({
84
+ status: 200,
85
+ json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
86
+ });
87
+ await expect(sendCommand('exec', { code: '1 + 1' })).resolves.toBe('ok');
88
+ await expect(sendCommand('exec', { code: '2 + 2' })).resolves.toBe('ok');
89
+ const ids = vi.mocked(fetch).mock.calls.map(([, init]) => {
90
+ const body = JSON.parse(String(init?.body));
91
+ return body.id;
92
+ });
93
+ expect(ids).toHaveLength(2);
94
+ expect(ids[0]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
95
+ expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
96
+ expect(ids[0]).not.toBe(ids[1]);
97
+ });
98
+ it('sendCommand retries with a new id when daemon reports a duplicate pending id', async () => {
99
+ vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_123);
100
+ const fetchMock = vi.mocked(fetch);
101
+ fetchMock
102
+ .mockResolvedValueOnce({
103
+ ok: false,
104
+ status: 409,
105
+ json: () => Promise.resolve({ ok: false, error: 'Duplicate command id already pending; retry' }),
106
+ })
107
+ .mockResolvedValueOnce({
108
+ ok: true,
109
+ status: 200,
110
+ json: () => Promise.resolve({ id: 'server', ok: true, data: 42 }),
111
+ });
112
+ await expect(sendCommand('exec', { code: '6 * 7' })).resolves.toBe(42);
113
+ expect(fetchMock).toHaveBeenCalledTimes(2);
114
+ const ids = fetchMock.mock.calls.map(([, init]) => {
115
+ const body = JSON.parse(String(init?.body));
116
+ return body.id;
117
+ });
118
+ expect(ids[0]).not.toBe(ids[1]);
119
+ });
81
120
  });
@@ -577,6 +577,7 @@ export function generateSnapshotJs(opts = {}) {
577
577
  const currentHashes = [];
578
578
  const refIdentity = {};
579
579
  let iframeCount = 0;
580
+ let crossOriginIndex = 0;
580
581
 
581
582
  function walk(el, depth, parentPropagatingRect) {
582
583
  if (depth > MAX_DEPTH) return false;
@@ -757,7 +758,9 @@ export function generateSnapshotJs(opts = {}) {
757
758
  const doc = el.contentDocument;
758
759
  if (!doc || !doc.body) {
759
760
  const attrs = serializeAttrs(el);
760
- lines.push(indent + '|iframe|<iframe' + (attrs ? ' ' + attrs : '') + ' /> (cross-origin)');
761
+ const frameLabel = '[F' + crossOriginIndex + ']';
762
+ lines.push(indent + '|iframe|' + frameLabel + '<iframe' + (attrs ? ' ' + attrs : '') + ' /> (cross-origin, use: opencli browser frames + browser eval --frame <index>)');
763
+ crossOriginIndex++;
761
764
  return false;
762
765
  }
763
766
  iframeCount++;
@@ -770,7 +773,9 @@ export function generateSnapshotJs(opts = {}) {
770
773
  return has;
771
774
  } catch {
772
775
  const attrs = serializeAttrs(el);
773
- lines.push(indent + '|iframe|<iframe' + (attrs ? ' ' + attrs : '') + ' /> (blocked)');
776
+ const frameLabel = '[F' + crossOriginIndex + ']';
777
+ lines.push(indent + '|iframe|' + frameLabel + '<iframe' + (attrs ? ' ' + attrs : '') + ' /> (blocked, use: opencli browser frames + browser eval --frame <index>)');
778
+ crossOriginIndex++;
774
779
  return false;
775
780
  }
776
781
  }
@@ -15,7 +15,8 @@ import { BasePage } from './base-page.js';
15
15
  */
16
16
  export declare class Page extends BasePage {
17
17
  private readonly workspace;
18
- constructor(workspace?: string);
18
+ private readonly _idleTimeout;
19
+ constructor(workspace?: string, idleTimeout?: number);
19
20
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
20
21
  private _page;
21
22
  private _networkCaptureUnsupported;
@@ -30,8 +31,8 @@ export declare class Page extends BasePage {
30
31
  }): Promise<void>;
31
32
  /** Get the active page identity (targetId) */
32
33
  getActivePage(): string | undefined;
33
- /** @deprecated Use getActivePage() instead */
34
- getActiveTabId(): number | undefined;
34
+ /** Bind this Page instance to a specific page identity (targetId). */
35
+ setActivePage(page?: string): void;
35
36
  private _markUnsupportedNetworkCapture;
36
37
  evaluate(js: string): Promise<unknown>;
37
38
  getCookies(opts?: {
@@ -41,7 +42,9 @@ export declare class Page extends BasePage {
41
42
  /** Close the automation window in the extension */
42
43
  closeWindow(): Promise<void>;
43
44
  tabs(): Promise<unknown[]>;
44
- selectTab(index: number): Promise<void>;
45
+ newTab(url?: string): Promise<string | undefined>;
46
+ closeTab(target?: number | string): Promise<void>;
47
+ selectTab(target: number | string): Promise<void>;
45
48
  /**
46
49
  * Capture a screenshot via CDP Page.captureScreenshot.
47
50
  */
@@ -55,6 +58,13 @@ export declare class Page extends BasePage {
55
58
  */
56
59
  setFileInput(files: string[], selector?: string): Promise<void>;
57
60
  insertText(text: string): Promise<void>;
61
+ frames(): Promise<Array<{
62
+ index: number;
63
+ frameId: string;
64
+ url: string;
65
+ name: string;
66
+ }>>;
67
+ evaluateInFrame(js: string, frameIndex: number): Promise<unknown>;
58
68
  cdp(method: string, params?: Record<string, unknown>): Promise<unknown>;
59
69
  /** CDP native click fallback — called when JS el.click() fails */
60
70
  protected tryNativeClick(x: number, y: number): Promise<boolean>;