@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.
- package/README.md +76 -51
- package/README.zh-CN.md +78 -62
- package/cli-manifest.json +4558 -2979
- 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/bilibili/video.js +61 -0
- package/clis/bilibili/video.test.js +81 -0
- package/clis/deepseek/ask.js +94 -0
- package/clis/deepseek/ask.test.js +73 -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 +291 -0
- package/clis/deepseek/utils.test.js +37 -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/jianyu/search.js +139 -3
- package/clis/jianyu/search.test.js +25 -0
- package/clis/jianyu/shared/procurement-detail.js +15 -0
- package/clis/jianyu/shared/procurement-detail.test.js +12 -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/twitter/shared.js +7 -2
- package/clis/twitter/tweets.js +218 -0
- package/clis/twitter/tweets.test.js +125 -0
- 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/youtube/channel.js +35 -0
- 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 +14 -4
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +13 -4
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.js +175 -0
- 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.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +83 -5
- package/dist/src/browser/dom-snapshot.test.js +65 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +76 -0
- package/dist/src/browser/find.js +179 -0
- package/dist/src/browser/find.test.js +120 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/network-cache.d.ts +48 -0
- package/dist/src/browser/network-cache.js +66 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +58 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- 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/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/target-errors.d.ts +14 -1
- package/dist/src/browser/target-errors.js +13 -0
- package/dist/src/browser/target-errors.test.js +39 -6
- package/dist/src/browser/target-resolver.d.ts +57 -10
- package/dist/src/browser/target-resolver.js +195 -75
- package/dist/src/browser/target-resolver.test.js +80 -5
- package/dist/src/cli.js +849 -267
- package/dist/src/cli.test.js +961 -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.d.ts +1 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/main.js +16 -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 +32 -8
- 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.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.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 → browser/compound.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
- /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 {
|
|
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/youtube/channel.js
CHANGED
|
@@ -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,
|
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
|
+
});
|
|
@@ -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(
|
|
37
|
-
click(ref: string): Promise<
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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()));
|
|
@@ -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
|
@@ -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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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(
|
|
308
|
+
async selectTab(_target) {
|
|
300
309
|
// Not supported in direct CDP mode
|
|
301
310
|
}
|
|
302
311
|
}
|