@jackwener/opencli 1.5.0 → 1.5.2
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/dist/browser/cdp.js +5 -0
- package/dist/browser/discover.js +11 -7
- package/dist/browser/index.d.ts +2 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/page.d.ts +4 -0
- package/dist/browser/page.js +52 -3
- package/dist/browser.test.js +5 -0
- package/dist/cli-manifest.json +460 -1
- package/dist/cli.js +34 -3
- package/dist/clis/apple-podcasts/commands.test.js +26 -3
- package/dist/clis/apple-podcasts/top.js +4 -1
- package/dist/clis/bluesky/feeds.yaml +29 -0
- package/dist/clis/bluesky/followers.yaml +33 -0
- package/dist/clis/bluesky/following.yaml +33 -0
- package/dist/clis/bluesky/profile.yaml +27 -0
- package/dist/clis/bluesky/search.yaml +34 -0
- package/dist/clis/bluesky/starter-packs.yaml +34 -0
- package/dist/clis/bluesky/thread.yaml +32 -0
- package/dist/clis/bluesky/trending.yaml +27 -0
- package/dist/clis/bluesky/user.yaml +34 -0
- package/dist/clis/twitter/trending.js +29 -61
- package/dist/clis/weread/shelf.js +132 -9
- package/dist/clis/weread/utils.js +5 -1
- package/dist/clis/xiaohongshu/publish.js +78 -42
- package/dist/clis/xiaohongshu/publish.test.js +20 -8
- package/dist/clis/xiaohongshu/search.d.ts +8 -1
- package/dist/clis/xiaohongshu/search.js +20 -1
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
- package/dist/clis/xiaohongshu/search.test.js +32 -1
- package/dist/daemon.js +1 -0
- package/dist/discovery.js +40 -28
- package/dist/doctor.d.ts +1 -2
- package/dist/doctor.js +9 -5
- package/dist/engine.test.js +42 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +2 -2
- package/dist/execution.js +45 -13
- package/dist/execution.test.d.ts +1 -0
- package/dist/execution.test.js +40 -0
- package/dist/extension-manifest-regression.test.d.ts +1 -0
- package/dist/extension-manifest-regression.test.js +12 -0
- package/dist/external.js +6 -1
- package/dist/main.js +1 -0
- package/dist/plugin-scaffold.d.ts +28 -0
- package/dist/plugin-scaffold.js +142 -0
- package/dist/plugin-scaffold.test.d.ts +4 -0
- package/dist/plugin-scaffold.test.js +83 -0
- package/dist/plugin.d.ts +55 -17
- package/dist/plugin.js +706 -154
- package/dist/plugin.test.js +836 -38
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/weread-private-api-regression.test.js +185 -0
- package/docs/adapters/browser/bluesky.md +53 -0
- package/docs/guide/plugins.md +10 -0
- package/extension/dist/background.js +4 -2
- package/extension/manifest.json +4 -1
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +2 -1
- package/package.json +1 -1
- package/src/browser/cdp.ts +6 -0
- package/src/browser/discover.ts +10 -7
- package/src/browser/index.ts +2 -0
- package/src/browser/page.ts +49 -3
- package/src/browser.test.ts +6 -0
- package/src/cli.ts +34 -3
- package/src/clis/apple-podcasts/commands.test.ts +30 -2
- package/src/clis/apple-podcasts/top.ts +4 -1
- package/src/clis/bluesky/feeds.yaml +29 -0
- package/src/clis/bluesky/followers.yaml +33 -0
- package/src/clis/bluesky/following.yaml +33 -0
- package/src/clis/bluesky/profile.yaml +27 -0
- package/src/clis/bluesky/search.yaml +34 -0
- package/src/clis/bluesky/starter-packs.yaml +34 -0
- package/src/clis/bluesky/thread.yaml +32 -0
- package/src/clis/bluesky/trending.yaml +27 -0
- package/src/clis/bluesky/user.yaml +34 -0
- package/src/clis/twitter/trending.ts +29 -77
- package/src/clis/weread/shelf.ts +169 -9
- package/src/clis/weread/utils.ts +6 -1
- package/src/clis/xiaohongshu/publish.test.ts +22 -8
- package/src/clis/xiaohongshu/publish.ts +93 -52
- package/src/clis/xiaohongshu/search.test.ts +39 -1
- package/src/clis/xiaohongshu/search.ts +19 -1
- package/src/daemon.ts +1 -0
- package/src/discovery.ts +41 -33
- package/src/doctor.ts +11 -8
- package/src/engine.test.ts +38 -0
- package/src/errors.ts +6 -2
- package/src/execution.test.ts +47 -0
- package/src/execution.ts +39 -15
- package/src/extension-manifest-regression.test.ts +17 -0
- package/src/external.ts +6 -1
- package/src/main.ts +1 -0
- package/src/plugin-scaffold.test.ts +98 -0
- package/src/plugin-scaffold.ts +170 -0
- package/src/plugin.test.ts +881 -38
- package/src/plugin.ts +871 -158
- package/src/runtime.ts +2 -2
- package/src/types.ts +2 -0
- package/src/weread-private-api-regression.test.ts +207 -0
- package/tests/e2e/browser-public.test.ts +1 -1
- package/tests/e2e/output-formats.test.ts +10 -14
- package/tests/e2e/plugin-management.test.ts +4 -1
- package/tests/e2e/public-commands.test.ts +12 -1
- package/vitest.config.ts +1 -15
|
@@ -38,13 +38,14 @@ describe('apple-podcasts search command', () => {
|
|
|
38
38
|
'https://itunes.apple.com/search?term=machine%20learning&media=podcast&limit=5',
|
|
39
39
|
);
|
|
40
40
|
expect(result).toEqual([
|
|
41
|
-
{
|
|
41
|
+
expect.objectContaining({
|
|
42
42
|
id: 42,
|
|
43
43
|
title: 'Machine Learning Guide',
|
|
44
44
|
author: 'OpenCLI',
|
|
45
45
|
episodes: 12,
|
|
46
46
|
genre: 'Technology',
|
|
47
|
-
|
|
47
|
+
url: '',
|
|
48
|
+
}),
|
|
48
49
|
]);
|
|
49
50
|
});
|
|
50
51
|
});
|
|
@@ -54,6 +55,30 @@ describe('apple-podcasts top command', () => {
|
|
|
54
55
|
vi.restoreAllMocks();
|
|
55
56
|
});
|
|
56
57
|
|
|
58
|
+
it('adds a timeout signal to chart fetches', async () => {
|
|
59
|
+
const cmd = getRegistry().get('apple-podcasts/top');
|
|
60
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
61
|
+
|
|
62
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
63
|
+
ok: true,
|
|
64
|
+
json: () => Promise.resolve({
|
|
65
|
+
feed: {
|
|
66
|
+
results: [
|
|
67
|
+
{ id: '100', name: 'Top Show', artistName: 'Host A' },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
73
|
+
|
|
74
|
+
await cmd!.func!(null as any, { country: 'US', limit: 1 });
|
|
75
|
+
|
|
76
|
+
const [, options] = fetchMock.mock.calls[0] ?? [];
|
|
77
|
+
expect(options).toBeDefined();
|
|
78
|
+
expect(options.signal).toBeDefined();
|
|
79
|
+
expect(options.signal).toHaveProperty('aborted', false);
|
|
80
|
+
});
|
|
81
|
+
|
|
57
82
|
it('uses the canonical Apple charts host and maps ranked results', async () => {
|
|
58
83
|
const cmd = getRegistry().get('apple-podcasts/top');
|
|
59
84
|
expect(cmd?.func).toBeTypeOf('function');
|
|
@@ -75,6 +100,9 @@ describe('apple-podcasts top command', () => {
|
|
|
75
100
|
|
|
76
101
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
77
102
|
'https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json',
|
|
103
|
+
expect.objectContaining({
|
|
104
|
+
signal: expect.any(Object),
|
|
105
|
+
}),
|
|
78
106
|
);
|
|
79
107
|
expect(result).toEqual([
|
|
80
108
|
{ rank: 1, title: 'Top Show', author: 'Host A', id: '100' },
|
|
@@ -3,6 +3,7 @@ import { CliError } from '../../errors.js';
|
|
|
3
3
|
|
|
4
4
|
// Apple Marketing Tools RSS API — public, no key required
|
|
5
5
|
const CHARTS_URL = 'https://rss.marketingtools.apple.com/api/v2';
|
|
6
|
+
const CHARTS_TIMEOUT_MS = 15_000;
|
|
6
7
|
|
|
7
8
|
cli({
|
|
8
9
|
site: 'apple-podcasts',
|
|
@@ -21,7 +22,9 @@ cli({
|
|
|
21
22
|
const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
|
|
22
23
|
let resp: Response;
|
|
23
24
|
try {
|
|
24
|
-
resp = await fetch(url
|
|
25
|
+
resp = await fetch(url, {
|
|
26
|
+
signal: AbortSignal.timeout(CHARTS_TIMEOUT_MS),
|
|
27
|
+
});
|
|
25
28
|
} catch (error: any) {
|
|
26
29
|
const reason = error?.cause?.code ?? error?.message ?? 'unknown network error';
|
|
27
30
|
throw new CliError(
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
site: bluesky
|
|
2
|
+
name: feeds
|
|
3
|
+
description: Popular Bluesky feed generators
|
|
4
|
+
domain: public.api.bsky.app
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
limit:
|
|
10
|
+
type: int
|
|
11
|
+
default: 20
|
|
12
|
+
description: Number of feeds
|
|
13
|
+
|
|
14
|
+
pipeline:
|
|
15
|
+
- fetch:
|
|
16
|
+
url: https://public.api.bsky.app/xrpc/app.bsky.unspecced.getPopularFeedGenerators?limit=${{ args.limit }}
|
|
17
|
+
|
|
18
|
+
- select: feeds
|
|
19
|
+
|
|
20
|
+
- map:
|
|
21
|
+
rank: ${{ index + 1 }}
|
|
22
|
+
name: ${{ item.displayName }}
|
|
23
|
+
likes: ${{ item.likeCount }}
|
|
24
|
+
creator: ${{ item.creator.handle }}
|
|
25
|
+
description: ${{ item.description }}
|
|
26
|
+
|
|
27
|
+
- limit: ${{ args.limit }}
|
|
28
|
+
|
|
29
|
+
columns: [rank, name, likes, creator, description]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
site: bluesky
|
|
2
|
+
name: followers
|
|
3
|
+
description: List followers of a Bluesky user
|
|
4
|
+
domain: public.api.bsky.app
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
handle:
|
|
10
|
+
type: str
|
|
11
|
+
required: true
|
|
12
|
+
positional: true
|
|
13
|
+
description: "Bluesky handle"
|
|
14
|
+
limit:
|
|
15
|
+
type: int
|
|
16
|
+
default: 20
|
|
17
|
+
description: Number of followers
|
|
18
|
+
|
|
19
|
+
pipeline:
|
|
20
|
+
- fetch:
|
|
21
|
+
url: https://public.api.bsky.app/xrpc/app.bsky.graph.getFollowers?actor=${{ args.handle }}&limit=${{ args.limit }}
|
|
22
|
+
|
|
23
|
+
- select: followers
|
|
24
|
+
|
|
25
|
+
- map:
|
|
26
|
+
rank: ${{ index + 1 }}
|
|
27
|
+
handle: ${{ item.handle }}
|
|
28
|
+
name: ${{ item.displayName }}
|
|
29
|
+
description: ${{ item.description }}
|
|
30
|
+
|
|
31
|
+
- limit: ${{ args.limit }}
|
|
32
|
+
|
|
33
|
+
columns: [rank, handle, name, description]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
site: bluesky
|
|
2
|
+
name: following
|
|
3
|
+
description: List accounts a Bluesky user is following
|
|
4
|
+
domain: public.api.bsky.app
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
handle:
|
|
10
|
+
type: str
|
|
11
|
+
required: true
|
|
12
|
+
positional: true
|
|
13
|
+
description: "Bluesky handle"
|
|
14
|
+
limit:
|
|
15
|
+
type: int
|
|
16
|
+
default: 20
|
|
17
|
+
description: Number of accounts
|
|
18
|
+
|
|
19
|
+
pipeline:
|
|
20
|
+
- fetch:
|
|
21
|
+
url: https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${{ args.handle }}&limit=${{ args.limit }}
|
|
22
|
+
|
|
23
|
+
- select: follows
|
|
24
|
+
|
|
25
|
+
- map:
|
|
26
|
+
rank: ${{ index + 1 }}
|
|
27
|
+
handle: ${{ item.handle }}
|
|
28
|
+
name: ${{ item.displayName }}
|
|
29
|
+
description: ${{ item.description }}
|
|
30
|
+
|
|
31
|
+
- limit: ${{ args.limit }}
|
|
32
|
+
|
|
33
|
+
columns: [rank, handle, name, description]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
site: bluesky
|
|
2
|
+
name: profile
|
|
3
|
+
description: Get Bluesky user profile info
|
|
4
|
+
domain: public.api.bsky.app
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
handle:
|
|
10
|
+
type: str
|
|
11
|
+
required: true
|
|
12
|
+
positional: true
|
|
13
|
+
description: "Bluesky handle (e.g. bsky.app, jay.bsky.team)"
|
|
14
|
+
|
|
15
|
+
pipeline:
|
|
16
|
+
- fetch:
|
|
17
|
+
url: https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{ args.handle }}
|
|
18
|
+
|
|
19
|
+
- map:
|
|
20
|
+
handle: ${{ item.handle }}
|
|
21
|
+
name: ${{ item.displayName }}
|
|
22
|
+
followers: ${{ item.followersCount }}
|
|
23
|
+
following: ${{ item.followsCount }}
|
|
24
|
+
posts: ${{ item.postsCount }}
|
|
25
|
+
description: ${{ item.description }}
|
|
26
|
+
|
|
27
|
+
columns: [handle, name, followers, following, posts, description]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
site: bluesky
|
|
2
|
+
name: search
|
|
3
|
+
description: Search Bluesky users
|
|
4
|
+
domain: public.api.bsky.app
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
query:
|
|
10
|
+
type: str
|
|
11
|
+
required: true
|
|
12
|
+
positional: true
|
|
13
|
+
description: Search query
|
|
14
|
+
limit:
|
|
15
|
+
type: int
|
|
16
|
+
default: 10
|
|
17
|
+
description: Number of results
|
|
18
|
+
|
|
19
|
+
pipeline:
|
|
20
|
+
- fetch:
|
|
21
|
+
url: https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors?q=${{ args.query }}&limit=${{ args.limit }}
|
|
22
|
+
|
|
23
|
+
- select: actors
|
|
24
|
+
|
|
25
|
+
- map:
|
|
26
|
+
rank: ${{ index + 1 }}
|
|
27
|
+
handle: ${{ item.handle }}
|
|
28
|
+
name: ${{ item.displayName }}
|
|
29
|
+
followers: ${{ item.followersCount }}
|
|
30
|
+
description: ${{ item.description }}
|
|
31
|
+
|
|
32
|
+
- limit: ${{ args.limit }}
|
|
33
|
+
|
|
34
|
+
columns: [rank, handle, name, followers, description]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
site: bluesky
|
|
2
|
+
name: starter-packs
|
|
3
|
+
description: Get starter packs created by a Bluesky user
|
|
4
|
+
domain: public.api.bsky.app
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
handle:
|
|
10
|
+
type: str
|
|
11
|
+
required: true
|
|
12
|
+
positional: true
|
|
13
|
+
description: "Bluesky handle"
|
|
14
|
+
limit:
|
|
15
|
+
type: int
|
|
16
|
+
default: 10
|
|
17
|
+
description: Number of starter packs
|
|
18
|
+
|
|
19
|
+
pipeline:
|
|
20
|
+
- fetch:
|
|
21
|
+
url: https://public.api.bsky.app/xrpc/app.bsky.graph.getActorStarterPacks?actor=${{ args.handle }}&limit=${{ args.limit }}
|
|
22
|
+
|
|
23
|
+
- select: starterPacks
|
|
24
|
+
|
|
25
|
+
- map:
|
|
26
|
+
rank: ${{ index + 1 }}
|
|
27
|
+
name: ${{ item.record.name }}
|
|
28
|
+
description: ${{ item.record.description }}
|
|
29
|
+
members: ${{ item.listItemCount }}
|
|
30
|
+
joins: ${{ item.joinedAllTimeCount }}
|
|
31
|
+
|
|
32
|
+
- limit: ${{ args.limit }}
|
|
33
|
+
|
|
34
|
+
columns: [rank, name, description, members, joins]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
site: bluesky
|
|
2
|
+
name: thread
|
|
3
|
+
description: Get a Bluesky post thread with replies
|
|
4
|
+
domain: public.api.bsky.app
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
uri:
|
|
10
|
+
type: str
|
|
11
|
+
required: true
|
|
12
|
+
positional: true
|
|
13
|
+
description: "Post AT URI (at://did:.../app.bsky.feed.post/...) or bsky.app URL"
|
|
14
|
+
limit:
|
|
15
|
+
type: int
|
|
16
|
+
default: 20
|
|
17
|
+
description: Number of replies
|
|
18
|
+
|
|
19
|
+
pipeline:
|
|
20
|
+
- fetch:
|
|
21
|
+
url: https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${{ args.uri }}&depth=2
|
|
22
|
+
|
|
23
|
+
- select: thread
|
|
24
|
+
|
|
25
|
+
- map:
|
|
26
|
+
author: ${{ item.post.author.handle }}
|
|
27
|
+
text: ${{ item.post.record.text }}
|
|
28
|
+
likes: ${{ item.post.likeCount }}
|
|
29
|
+
reposts: ${{ item.post.repostCount }}
|
|
30
|
+
replies_count: ${{ item.post.replyCount }}
|
|
31
|
+
|
|
32
|
+
columns: [author, text, likes, reposts, replies_count]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
site: bluesky
|
|
2
|
+
name: trending
|
|
3
|
+
description: Trending topics on Bluesky
|
|
4
|
+
domain: public.api.bsky.app
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
limit:
|
|
10
|
+
type: int
|
|
11
|
+
default: 20
|
|
12
|
+
description: Number of topics
|
|
13
|
+
|
|
14
|
+
pipeline:
|
|
15
|
+
- fetch:
|
|
16
|
+
url: https://public.api.bsky.app/xrpc/app.bsky.unspecced.getTrendingTopics
|
|
17
|
+
|
|
18
|
+
- select: topics
|
|
19
|
+
|
|
20
|
+
- map:
|
|
21
|
+
rank: ${{ index + 1 }}
|
|
22
|
+
topic: ${{ item.topic }}
|
|
23
|
+
link: ${{ item.link }}
|
|
24
|
+
|
|
25
|
+
- limit: ${{ args.limit }}
|
|
26
|
+
|
|
27
|
+
columns: [rank, topic, link]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
site: bluesky
|
|
2
|
+
name: user
|
|
3
|
+
description: Get recent posts from a Bluesky user
|
|
4
|
+
domain: public.api.bsky.app
|
|
5
|
+
strategy: public
|
|
6
|
+
browser: false
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
handle:
|
|
10
|
+
type: str
|
|
11
|
+
required: true
|
|
12
|
+
positional: true
|
|
13
|
+
description: "Bluesky handle (e.g. bsky.app)"
|
|
14
|
+
limit:
|
|
15
|
+
type: int
|
|
16
|
+
default: 20
|
|
17
|
+
description: Number of posts
|
|
18
|
+
|
|
19
|
+
pipeline:
|
|
20
|
+
- fetch:
|
|
21
|
+
url: https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${{ args.handle }}&limit=${{ args.limit }}
|
|
22
|
+
|
|
23
|
+
- select: feed
|
|
24
|
+
|
|
25
|
+
- map:
|
|
26
|
+
rank: ${{ index + 1 }}
|
|
27
|
+
text: ${{ item.post.record.text }}
|
|
28
|
+
likes: ${{ item.post.likeCount }}
|
|
29
|
+
reposts: ${{ item.post.repostCount }}
|
|
30
|
+
replies: ${{ item.post.replyCount }}
|
|
31
|
+
|
|
32
|
+
- limit: ${{ args.limit }}
|
|
33
|
+
|
|
34
|
+
columns: [rank, text, likes, reposts, replies]
|
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
2
|
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
|
3
3
|
|
|
4
|
-
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
5
|
-
|
|
6
|
-
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
7
|
-
|
|
8
|
-
// ── Types ──────────────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
interface TrendItem {
|
|
11
|
-
rank: number;
|
|
12
|
-
topic: string;
|
|
13
|
-
tweets: string;
|
|
14
|
-
category: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
4
|
// ── CLI definition ────────────────────────────────────────────────────
|
|
18
5
|
|
|
19
6
|
cli({
|
|
@@ -34,79 +21,44 @@ cli({
|
|
|
34
21
|
await page.goto('https://x.com/explore/tabs/trending');
|
|
35
22
|
await page.wait(3);
|
|
36
23
|
|
|
37
|
-
//
|
|
24
|
+
// Verify login via CSRF cookie
|
|
38
25
|
const ct0 = await page.evaluate(`(() => {
|
|
39
26
|
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
|
40
27
|
})()`);
|
|
41
28
|
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
42
29
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
30
|
+
// Scrape trends from DOM (consistent with what the user sees on the page)
|
|
31
|
+
// DOM children: [0] rank + category, [1] topic, optional post count,
|
|
32
|
+
// and a caret menu button identified by [data-testid="caret"].
|
|
33
|
+
await page.wait(2);
|
|
34
|
+
const trends = await page.evaluate(`(() => {
|
|
35
|
+
const items = [];
|
|
36
|
+
const cells = document.querySelectorAll('[data-testid="trend"]');
|
|
37
|
+
cells.forEach((cell) => {
|
|
38
|
+
const text = cell.textContent || '';
|
|
39
|
+
if (text.includes('Promoted')) return;
|
|
40
|
+
const container = cell.querySelector(':scope > div');
|
|
41
|
+
if (!container) return;
|
|
42
|
+
const divs = container.children;
|
|
43
|
+
if (divs.length < 2) return;
|
|
44
|
+
const topic = divs[1].textContent.trim();
|
|
45
|
+
if (!topic) return;
|
|
46
|
+
const catText = divs[0].textContent.trim();
|
|
47
|
+
const category = catText.replace(/^\\d+\\s*/, '').replace(/^\\xB7\\s*/, '').trim();
|
|
48
|
+
// Find post count: skip rank, topic, and the caret menu button
|
|
49
|
+
let tweets = 'N/A';
|
|
50
|
+
for (let j = 2; j < divs.length; j++) {
|
|
51
|
+
if (divs[j].matches('[data-testid="caret"]') || divs[j].querySelector('[data-testid="caret"]')) continue;
|
|
52
|
+
const t = divs[j].textContent.trim();
|
|
53
|
+
if (t && /\\d/.test(t)) { tweets = t; break; }
|
|
54
54
|
}
|
|
55
|
+
items.push({ rank: items.length + 1, topic, tweets, category });
|
|
55
56
|
});
|
|
56
|
-
return
|
|
57
|
+
return items;
|
|
57
58
|
})()`);
|
|
58
59
|
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
const entries = instructions.flatMap((inst: any) => inst?.addEntries?.entries || inst?.entries || []);
|
|
62
|
-
const apiTrends = entries
|
|
63
|
-
.filter((e: any) => e.content?.timelineModule)
|
|
64
|
-
.flatMap((e: any) => e.content.timelineModule.items || [])
|
|
65
|
-
.map((t: any) => t?.item?.content?.trend)
|
|
66
|
-
.filter(Boolean);
|
|
67
|
-
|
|
68
|
-
trends = apiTrends.map((t: any, i: number) => ({
|
|
69
|
-
rank: i + 1,
|
|
70
|
-
topic: t.name,
|
|
71
|
-
tweets: t.tweetCount ? String(t.tweetCount) : 'N/A',
|
|
72
|
-
category: t.trendMetadata?.domainContext || '',
|
|
73
|
-
}));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Fallback: scrape from the loaded DOM
|
|
77
|
-
if (trends.length === 0) {
|
|
78
|
-
await page.wait(2);
|
|
79
|
-
const domTrends = await page.evaluate(`(() => {
|
|
80
|
-
const items = [];
|
|
81
|
-
const cells = document.querySelectorAll('[data-testid="trend"]');
|
|
82
|
-
cells.forEach((cell) => {
|
|
83
|
-
const text = cell.textContent || '';
|
|
84
|
-
if (text.includes('Promoted')) return;
|
|
85
|
-
const container = cell.querySelector(':scope > div');
|
|
86
|
-
if (!container) return;
|
|
87
|
-
const divs = container.children;
|
|
88
|
-
// Structure: divs[0] = rank + category, divs[1] = topic name, divs[2] = extra
|
|
89
|
-
const topicEl = divs.length >= 2 ? divs[1] : null;
|
|
90
|
-
const topic = topicEl ? topicEl.textContent.trim() : '';
|
|
91
|
-
const catEl = divs.length >= 1 ? divs[0] : null;
|
|
92
|
-
const catText = catEl ? catEl.textContent.trim() : '';
|
|
93
|
-
const category = catText.replace(/^\\d+\\s*/, '').replace(/^\\xB7\\s*/, '').trim();
|
|
94
|
-
const extraEl = divs.length >= 3 ? divs[2] : null;
|
|
95
|
-
const extra = extraEl ? extraEl.textContent.trim() : '';
|
|
96
|
-
if (topic) {
|
|
97
|
-
items.push({ rank: items.length + 1, topic, tweets: extra || 'N/A', category });
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
return items;
|
|
101
|
-
})()`);
|
|
102
|
-
|
|
103
|
-
if (Array.isArray(domTrends) && domTrends.length > 0) {
|
|
104
|
-
trends = domTrends;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (trends.length === 0) {
|
|
109
|
-
throw new EmptyResultError('twitter trending', 'API may have changed or login may be required.');
|
|
60
|
+
if (!Array.isArray(trends) || trends.length === 0) {
|
|
61
|
+
throw new EmptyResultError('twitter trending', 'No trends found. The page structure may have changed.');
|
|
110
62
|
}
|
|
111
63
|
|
|
112
64
|
return trends.slice(0, limit);
|
package/src/clis/weread/shelf.ts
CHANGED
|
@@ -1,7 +1,159 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
2
3
|
import type { IPage } from '../../types.js';
|
|
3
4
|
import { fetchPrivateApi } from './utils.js';
|
|
4
5
|
|
|
6
|
+
const WEREAD_DOMAIN = 'weread.qq.com';
|
|
7
|
+
const WEREAD_SHELF_URL = `https://${WEREAD_DOMAIN}/web/shelf`;
|
|
8
|
+
|
|
9
|
+
interface ShelfRow {
|
|
10
|
+
title: string;
|
|
11
|
+
author: string;
|
|
12
|
+
progress: string;
|
|
13
|
+
bookId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface WebShelfRawBook {
|
|
17
|
+
bookId?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
author?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface WebShelfIndexEntry {
|
|
23
|
+
bookId?: string;
|
|
24
|
+
idx?: number;
|
|
25
|
+
role?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface WebShelfSnapshot {
|
|
29
|
+
cacheFound: boolean;
|
|
30
|
+
rawBooks: WebShelfRawBook[];
|
|
31
|
+
shelfIndexes: WebShelfIndexEntry[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeShelfLimit(limit: number): number {
|
|
35
|
+
if (!Number.isFinite(limit)) return 0;
|
|
36
|
+
return Math.max(0, Math.trunc(limit));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizePrivateApiRows(data: any, limit: number): ShelfRow[] {
|
|
40
|
+
const books: any[] = data?.books ?? [];
|
|
41
|
+
return books.slice(0, limit).map((item: any) => ({
|
|
42
|
+
title: item.bookInfo?.title ?? item.title ?? '',
|
|
43
|
+
author: item.bookInfo?.author ?? item.author ?? '',
|
|
44
|
+
// TODO: readingProgress field name from community docs, verify with real API response
|
|
45
|
+
progress: item.readingProgress != null ? `${item.readingProgress}%` : '-',
|
|
46
|
+
bookId: item.bookId ?? item.bookInfo?.bookId ?? '',
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeWebShelfRows(snapshot: WebShelfSnapshot, limit: number): ShelfRow[] {
|
|
51
|
+
if (limit <= 0) return [];
|
|
52
|
+
|
|
53
|
+
const bookById = new Map<string, WebShelfRawBook>();
|
|
54
|
+
for (const book of snapshot.rawBooks) {
|
|
55
|
+
const bookId = String(book?.bookId || '').trim();
|
|
56
|
+
if (!bookId) continue;
|
|
57
|
+
bookById.set(bookId, book);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const orderedBookIds = snapshot.shelfIndexes
|
|
61
|
+
.filter((entry) => String(entry?.role || 'book') === 'book')
|
|
62
|
+
.sort((left, right) => Number(left?.idx ?? Number.MAX_SAFE_INTEGER) - Number(right?.idx ?? Number.MAX_SAFE_INTEGER))
|
|
63
|
+
.map((entry) => String(entry?.bookId || '').trim())
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
|
|
66
|
+
const fallbackOrder = snapshot.rawBooks
|
|
67
|
+
.map((book) => String(book?.bookId || '').trim())
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
|
|
70
|
+
const orderedUniqueBookIds = Array.from(new Set([
|
|
71
|
+
...orderedBookIds,
|
|
72
|
+
...fallbackOrder,
|
|
73
|
+
]));
|
|
74
|
+
|
|
75
|
+
return orderedUniqueBookIds
|
|
76
|
+
.map((bookId) => {
|
|
77
|
+
const book = bookById.get(bookId);
|
|
78
|
+
if (!book) return null;
|
|
79
|
+
return {
|
|
80
|
+
title: String(book.title || '').trim(),
|
|
81
|
+
author: String(book.author || '').trim(),
|
|
82
|
+
progress: '-',
|
|
83
|
+
bookId,
|
|
84
|
+
} satisfies ShelfRow;
|
|
85
|
+
})
|
|
86
|
+
.filter((item): item is ShelfRow => Boolean(item && (item.title || item.bookId)))
|
|
87
|
+
.slice(0, limit);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read the structured shelf cache from the web shelf page.
|
|
92
|
+
* The page hydrates localStorage with raw book data plus shelf ordering.
|
|
93
|
+
*/
|
|
94
|
+
async function loadWebShelfSnapshot(page: IPage): Promise<WebShelfSnapshot> {
|
|
95
|
+
await page.goto(WEREAD_SHELF_URL);
|
|
96
|
+
|
|
97
|
+
const cookies = await page.getCookies({ domain: WEREAD_DOMAIN });
|
|
98
|
+
const currentVid = String(cookies.find((cookie) => cookie.name === 'wr_vid')?.value || '').trim();
|
|
99
|
+
|
|
100
|
+
if (!currentVid) {
|
|
101
|
+
return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rawBooksKey = `shelf:rawBooks:${currentVid}`;
|
|
105
|
+
const shelfIndexesKey = `shelf:shelfIndexes:${currentVid}`;
|
|
106
|
+
|
|
107
|
+
const result = await page.evaluate(`
|
|
108
|
+
(() => new Promise((resolve) => {
|
|
109
|
+
const deadline = Date.now() + 5000;
|
|
110
|
+
const rawBooksKey = ${JSON.stringify(rawBooksKey)};
|
|
111
|
+
const shelfIndexesKey = ${JSON.stringify(shelfIndexesKey)};
|
|
112
|
+
|
|
113
|
+
const readJson = (raw) => {
|
|
114
|
+
if (typeof raw !== 'string') return null;
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(raw);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const poll = () => {
|
|
123
|
+
const rawBooksRaw = localStorage.getItem(rawBooksKey);
|
|
124
|
+
const shelfIndexesRaw = localStorage.getItem(shelfIndexesKey);
|
|
125
|
+
const rawBooks = readJson(rawBooksRaw);
|
|
126
|
+
const shelfIndexes = readJson(shelfIndexesRaw);
|
|
127
|
+
const cacheFound = Array.isArray(rawBooks);
|
|
128
|
+
|
|
129
|
+
if (cacheFound || Date.now() >= deadline) {
|
|
130
|
+
resolve({
|
|
131
|
+
cacheFound,
|
|
132
|
+
rawBooks: Array.isArray(rawBooks) ? rawBooks : [],
|
|
133
|
+
shelfIndexes: Array.isArray(shelfIndexes) ? shelfIndexes : [],
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setTimeout(poll, 100);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
poll();
|
|
142
|
+
}))
|
|
143
|
+
`);
|
|
144
|
+
|
|
145
|
+
if (!result || typeof result !== 'object') {
|
|
146
|
+
return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const snapshot = result as Partial<WebShelfSnapshot>;
|
|
150
|
+
return {
|
|
151
|
+
cacheFound: snapshot.cacheFound === true,
|
|
152
|
+
rawBooks: Array.isArray(snapshot.rawBooks) ? snapshot.rawBooks : [],
|
|
153
|
+
shelfIndexes: Array.isArray(snapshot.shelfIndexes) ? snapshot.shelfIndexes : [],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
5
157
|
cli({
|
|
6
158
|
site: 'weread',
|
|
7
159
|
name: 'shelf',
|
|
@@ -13,14 +165,22 @@ cli({
|
|
|
13
165
|
],
|
|
14
166
|
columns: ['title', 'author', 'progress', 'bookId'],
|
|
15
167
|
func: async (page: IPage, args) => {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
168
|
+
const limit = normalizeShelfLimit(Number(args.limit));
|
|
169
|
+
if (limit <= 0) return [];
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const data = await fetchPrivateApi(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' });
|
|
173
|
+
return normalizePrivateApiRows(data, limit);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (!(error instanceof CliError) || error.code !== 'AUTH_REQUIRED') {
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const snapshot = await loadWebShelfSnapshot(page);
|
|
180
|
+
if (!snapshot.cacheFound) {
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
return normalizeWebShelfRows(snapshot, limit);
|
|
184
|
+
}
|
|
25
185
|
},
|
|
26
186
|
});
|