@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.
Files changed (108) hide show
  1. package/dist/browser/cdp.js +5 -0
  2. package/dist/browser/discover.js +11 -7
  3. package/dist/browser/index.d.ts +2 -0
  4. package/dist/browser/index.js +2 -0
  5. package/dist/browser/page.d.ts +4 -0
  6. package/dist/browser/page.js +52 -3
  7. package/dist/browser.test.js +5 -0
  8. package/dist/cli-manifest.json +460 -1
  9. package/dist/cli.js +34 -3
  10. package/dist/clis/apple-podcasts/commands.test.js +26 -3
  11. package/dist/clis/apple-podcasts/top.js +4 -1
  12. package/dist/clis/bluesky/feeds.yaml +29 -0
  13. package/dist/clis/bluesky/followers.yaml +33 -0
  14. package/dist/clis/bluesky/following.yaml +33 -0
  15. package/dist/clis/bluesky/profile.yaml +27 -0
  16. package/dist/clis/bluesky/search.yaml +34 -0
  17. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  18. package/dist/clis/bluesky/thread.yaml +32 -0
  19. package/dist/clis/bluesky/trending.yaml +27 -0
  20. package/dist/clis/bluesky/user.yaml +34 -0
  21. package/dist/clis/twitter/trending.js +29 -61
  22. package/dist/clis/weread/shelf.js +132 -9
  23. package/dist/clis/weread/utils.js +5 -1
  24. package/dist/clis/xiaohongshu/publish.js +78 -42
  25. package/dist/clis/xiaohongshu/publish.test.js +20 -8
  26. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  27. package/dist/clis/xiaohongshu/search.js +20 -1
  28. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  29. package/dist/clis/xiaohongshu/search.test.js +32 -1
  30. package/dist/daemon.js +1 -0
  31. package/dist/discovery.js +40 -28
  32. package/dist/doctor.d.ts +1 -2
  33. package/dist/doctor.js +9 -5
  34. package/dist/engine.test.js +42 -0
  35. package/dist/errors.d.ts +1 -1
  36. package/dist/errors.js +2 -2
  37. package/dist/execution.js +45 -13
  38. package/dist/execution.test.d.ts +1 -0
  39. package/dist/execution.test.js +40 -0
  40. package/dist/extension-manifest-regression.test.d.ts +1 -0
  41. package/dist/extension-manifest-regression.test.js +12 -0
  42. package/dist/external.js +6 -1
  43. package/dist/main.js +1 -0
  44. package/dist/plugin-scaffold.d.ts +28 -0
  45. package/dist/plugin-scaffold.js +142 -0
  46. package/dist/plugin-scaffold.test.d.ts +4 -0
  47. package/dist/plugin-scaffold.test.js +83 -0
  48. package/dist/plugin.d.ts +55 -17
  49. package/dist/plugin.js +706 -154
  50. package/dist/plugin.test.js +836 -38
  51. package/dist/runtime.d.ts +1 -0
  52. package/dist/runtime.js +1 -1
  53. package/dist/types.d.ts +2 -0
  54. package/dist/weread-private-api-regression.test.js +185 -0
  55. package/docs/adapters/browser/bluesky.md +53 -0
  56. package/docs/guide/plugins.md +10 -0
  57. package/extension/dist/background.js +4 -2
  58. package/extension/manifest.json +4 -1
  59. package/extension/package-lock.json +2 -2
  60. package/extension/package.json +1 -1
  61. package/extension/src/background.ts +2 -1
  62. package/package.json +1 -1
  63. package/src/browser/cdp.ts +6 -0
  64. package/src/browser/discover.ts +10 -7
  65. package/src/browser/index.ts +2 -0
  66. package/src/browser/page.ts +49 -3
  67. package/src/browser.test.ts +6 -0
  68. package/src/cli.ts +34 -3
  69. package/src/clis/apple-podcasts/commands.test.ts +30 -2
  70. package/src/clis/apple-podcasts/top.ts +4 -1
  71. package/src/clis/bluesky/feeds.yaml +29 -0
  72. package/src/clis/bluesky/followers.yaml +33 -0
  73. package/src/clis/bluesky/following.yaml +33 -0
  74. package/src/clis/bluesky/profile.yaml +27 -0
  75. package/src/clis/bluesky/search.yaml +34 -0
  76. package/src/clis/bluesky/starter-packs.yaml +34 -0
  77. package/src/clis/bluesky/thread.yaml +32 -0
  78. package/src/clis/bluesky/trending.yaml +27 -0
  79. package/src/clis/bluesky/user.yaml +34 -0
  80. package/src/clis/twitter/trending.ts +29 -77
  81. package/src/clis/weread/shelf.ts +169 -9
  82. package/src/clis/weread/utils.ts +6 -1
  83. package/src/clis/xiaohongshu/publish.test.ts +22 -8
  84. package/src/clis/xiaohongshu/publish.ts +93 -52
  85. package/src/clis/xiaohongshu/search.test.ts +39 -1
  86. package/src/clis/xiaohongshu/search.ts +19 -1
  87. package/src/daemon.ts +1 -0
  88. package/src/discovery.ts +41 -33
  89. package/src/doctor.ts +11 -8
  90. package/src/engine.test.ts +38 -0
  91. package/src/errors.ts +6 -2
  92. package/src/execution.test.ts +47 -0
  93. package/src/execution.ts +39 -15
  94. package/src/extension-manifest-regression.test.ts +17 -0
  95. package/src/external.ts +6 -1
  96. package/src/main.ts +1 -0
  97. package/src/plugin-scaffold.test.ts +98 -0
  98. package/src/plugin-scaffold.ts +170 -0
  99. package/src/plugin.test.ts +881 -38
  100. package/src/plugin.ts +871 -158
  101. package/src/runtime.ts +2 -2
  102. package/src/types.ts +2 -0
  103. package/src/weread-private-api-regression.test.ts +207 -0
  104. package/tests/e2e/browser-public.test.ts +1 -1
  105. package/tests/e2e/output-formats.test.ts +10 -14
  106. package/tests/e2e/plugin-management.test.ts +4 -1
  107. package/tests/e2e/public-commands.test.ts +12 -1
  108. 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
- // Extract CSRF token to verify login
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
- // Try legacy guide.json API first (faster than DOM scraping)
44
- let trends: TrendItem[] = [];
45
-
46
- const apiData = await page.evaluate(`(async () => {
47
- const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || '';
48
- const r = await fetch('/i/api/2/guide.json?include_page_configuration=true', {
49
- credentials: 'include',
50
- headers: {
51
- 'x-twitter-active-user': 'yes',
52
- 'x-csrf-token': ct0,
53
- 'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'
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 r.ok ? await r.json() : null;
57
+ return items;
57
58
  })()`);
58
59
 
59
- if (apiData) {
60
- const instructions = apiData?.timeline?.instructions || [];
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);
@@ -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 data = await fetchPrivateApi(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' });
17
- const books: any[] = data?.books ?? [];
18
- return books.slice(0, Number(args.limit)).map((item: any) => ({
19
- title: item.bookInfo?.title ?? item.title ?? '',
20
- author: item.bookInfo?.author ?? item.author ?? '',
21
- // TODO: readingProgress field name from community docs, verify with real API response
22
- progress: item.readingProgress != null ? `${item.readingProgress}%` : '-',
23
- bookId: item.bookId ?? item.bookInfo?.bookId ?? '',
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
  });