@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.
- package/README.md +71 -49
- package/README.zh-CN.md +73 -60
- package/cli-manifest.json +3261 -1758
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/deepseek/ask.js +74 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +208 -0
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- package/clis/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- package/clis/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/download.js +8 -4
- package/clis/xiaoyuzhou/download.test.js +23 -13
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- package/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +1 -1
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.js +1 -1
- package/dist/src/browser/daemon-client.d.ts +6 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.js +7 -2
- package/dist/src/browser/page.d.ts +14 -4
- package/dist/src/browser/page.js +48 -7
- package/dist/src/browser/page.test.js +97 -0
- package/dist/src/cli.js +227 -150
- package/dist/src/cli.test.js +167 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- package/dist/src/completion-shared.js +2 -5
- package/dist/src/daemon.js +8 -0
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/plugin.d.ts +1 -8
- package/dist/src/plugin.js +1 -27
- package/dist/src/plugin.test.js +1 -59
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +14 -5
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.d.ts +0 -1
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.d.ts +0 -1
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /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 {
|
|
6
|
-
|
|
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('./
|
|
12
|
-
const actual = await vi.importActual('./
|
|
12
|
+
vi.mock('./auth.js', async () => {
|
|
13
|
+
const actual = await vi.importActual('./auth.js');
|
|
13
14
|
return {
|
|
14
15
|
...actual,
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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 {
|
|
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.
|
|
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
|
|
15
|
-
const
|
|
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 {
|
|
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
|
|
8
|
+
description: 'List episodes of a Xiaoyuzhou podcast',
|
|
8
9
|
domain: 'www.xiaoyuzhoufm.com',
|
|
9
|
-
strategy: Strategy.
|
|
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:
|
|
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
|
|
27
|
-
const
|
|
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 {
|
|
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.
|
|
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
|
|
15
|
-
const
|
|
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 [{
|
package/clis/xiaoyuzhou/utils.js
CHANGED
|
@@ -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
|
|
2
|
-
import { formatDuration, formatDate
|
|
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(
|
|
11
|
+
expect(formatDuration(62)).toBe('1:02');
|
|
12
12
|
});
|
|
13
|
-
it('
|
|
14
|
-
expect(formatDuration(
|
|
13
|
+
it('handles exact minutes', () => {
|
|
14
|
+
expect(formatDuration(120)).toBe('2:00');
|
|
15
15
|
});
|
|
16
|
-
it('rounds
|
|
17
|
-
expect(formatDuration(
|
|
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('
|
|
28
|
-
expect(formatDate('2026-03-
|
|
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
|
|
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('
|
|
52
|
-
|
|
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
|
});
|
package/clis/zsxq/dynamics.js
CHANGED
|
@@ -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)
|
|
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),
|
package/clis/zsxq/utils.js
CHANGED
|
@@ -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)
|
|
222
|
-
content:
|
|
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(
|
|
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>;
|
|
@@ -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
|
}
|
package/dist/src/browser/cdp.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
34
|
-
|
|
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
|
-
|
|
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>;
|