@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
package/dist/cli.js CHANGED
@@ -69,9 +69,10 @@ export function runCli(BUILTIN_CLIS, USER_CLIS) {
69
69
  for (const [site, cmds] of sites) {
70
70
  console.log(chalk.bold.cyan(` ${site}`));
71
71
  for (const cmd of cmds) {
72
- const tag = strategyLabel(cmd) === 'public'
72
+ const label = strategyLabel(cmd);
73
+ const tag = label === 'public'
73
74
  ? chalk.green('[public]')
74
- : chalk.yellow(`[${strategyLabel(cmd)}]`);
75
+ : chalk.yellow(`[${label}]`);
75
76
  console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`);
76
77
  }
77
78
  console.log();
@@ -228,7 +229,7 @@ export function runCli(BUILTIN_CLIS, USER_CLIS) {
228
229
  const pluginCmd = program.command('plugin').description('Manage opencli plugins');
229
230
  pluginCmd
230
231
  .command('install')
231
- .description('Install a plugin from GitHub')
232
+ .description('Install a plugin from a git repository')
232
233
  .argument('<source>', 'Plugin source (e.g. github:user/repo)')
233
234
  .action(async (source) => {
234
235
  const { installPlugin } = await import('./plugin.js');
@@ -380,6 +381,36 @@ export function runCli(BUILTIN_CLIS, USER_CLIS) {
380
381
  console.log(chalk.dim(` ${plugins.length} plugin(s) installed`));
381
382
  console.log();
382
383
  });
384
+ pluginCmd
385
+ .command('create')
386
+ .description('Create a new plugin scaffold')
387
+ .argument('<name>', 'Plugin name (lowercase, hyphens allowed)')
388
+ .option('-d, --dir <path>', 'Output directory (default: ./<name>)')
389
+ .option('--description <text>', 'Plugin description')
390
+ .action(async (name, opts) => {
391
+ const { createPluginScaffold } = await import('./plugin-scaffold.js');
392
+ try {
393
+ const result = createPluginScaffold(name, {
394
+ dir: opts.dir,
395
+ description: opts.description,
396
+ });
397
+ console.log(chalk.green(`✅ Plugin scaffold created at ${result.dir}`));
398
+ console.log();
399
+ console.log(chalk.bold(' Files created:'));
400
+ for (const f of result.files) {
401
+ console.log(` ${chalk.cyan(f)}`);
402
+ }
403
+ console.log();
404
+ console.log(chalk.dim(' Next steps:'));
405
+ console.log(chalk.dim(` cd ${result.dir}`));
406
+ console.log(chalk.dim(` opencli plugin install file://${result.dir}`));
407
+ console.log(chalk.dim(` opencli ${name} hello`));
408
+ }
409
+ catch (err) {
410
+ console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
411
+ process.exitCode = 1;
412
+ }
413
+ });
383
414
  // ── External CLIs ─────────────────────────────────────────────────────────
384
415
  const externalClis = loadExternalClis();
385
416
  program
@@ -31,13 +31,14 @@ describe('apple-podcasts search command', () => {
31
31
  });
32
32
  expect(fetchMock).toHaveBeenCalledWith('https://itunes.apple.com/search?term=machine%20learning&media=podcast&limit=5');
33
33
  expect(result).toEqual([
34
- {
34
+ expect.objectContaining({
35
35
  id: 42,
36
36
  title: 'Machine Learning Guide',
37
37
  author: 'OpenCLI',
38
38
  episodes: 12,
39
39
  genre: 'Technology',
40
- },
40
+ url: '',
41
+ }),
41
42
  ]);
42
43
  });
43
44
  });
@@ -45,6 +46,26 @@ describe('apple-podcasts top command', () => {
45
46
  beforeEach(() => {
46
47
  vi.restoreAllMocks();
47
48
  });
49
+ it('adds a timeout signal to chart fetches', async () => {
50
+ const cmd = getRegistry().get('apple-podcasts/top');
51
+ expect(cmd?.func).toBeTypeOf('function');
52
+ const fetchMock = vi.fn().mockResolvedValue({
53
+ ok: true,
54
+ json: () => Promise.resolve({
55
+ feed: {
56
+ results: [
57
+ { id: '100', name: 'Top Show', artistName: 'Host A' },
58
+ ],
59
+ },
60
+ }),
61
+ });
62
+ vi.stubGlobal('fetch', fetchMock);
63
+ await cmd.func(null, { country: 'US', limit: 1 });
64
+ const [, options] = fetchMock.mock.calls[0] ?? [];
65
+ expect(options).toBeDefined();
66
+ expect(options.signal).toBeDefined();
67
+ expect(options.signal).toHaveProperty('aborted', false);
68
+ });
48
69
  it('uses the canonical Apple charts host and maps ranked results', async () => {
49
70
  const cmd = getRegistry().get('apple-podcasts/top');
50
71
  expect(cmd?.func).toBeTypeOf('function');
@@ -61,7 +82,9 @@ describe('apple-podcasts top command', () => {
61
82
  });
62
83
  vi.stubGlobal('fetch', fetchMock);
63
84
  const result = await cmd.func(null, { country: 'US', limit: 2 });
64
- expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json');
85
+ expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json', expect.objectContaining({
86
+ signal: expect.any(Object),
87
+ }));
65
88
  expect(result).toEqual([
66
89
  { rank: 1, title: 'Top Show', author: 'Host A', id: '100' },
67
90
  { rank: 2, title: 'Second Show', author: 'Host B', id: '101' },
@@ -2,6 +2,7 @@ import { cli, Strategy } from '../../registry.js';
2
2
  import { CliError } from '../../errors.js';
3
3
  // Apple Marketing Tools RSS API — public, no key required
4
4
  const CHARTS_URL = 'https://rss.marketingtools.apple.com/api/v2';
5
+ const CHARTS_TIMEOUT_MS = 15_000;
5
6
  cli({
6
7
  site: 'apple-podcasts',
7
8
  name: 'top',
@@ -19,7 +20,9 @@ cli({
19
20
  const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
20
21
  let resp;
21
22
  try {
22
- resp = await fetch(url);
23
+ resp = await fetch(url, {
24
+ signal: AbortSignal.timeout(CHARTS_TIMEOUT_MS),
25
+ });
23
26
  }
24
27
  catch (error) {
25
28
  const reason = error?.cause?.code ?? error?.message ?? 'unknown network error';
@@ -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,7 +1,5 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
2
  import { AuthRequiredError, EmptyResultError } from '../../errors.js';
3
- // ── Twitter GraphQL constants ──────────────────────────────────────────
4
- const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
5
3
  // ── CLI definition ────────────────────────────────────────────────────
6
4
  cli({
7
5
  site: 'twitter',
@@ -19,73 +17,43 @@ cli({
19
17
  // Navigate to trending page
20
18
  await page.goto('https://x.com/explore/tabs/trending');
21
19
  await page.wait(3);
22
- // Extract CSRF token to verify login
20
+ // Verify login via CSRF cookie
23
21
  const ct0 = await page.evaluate(`(() => {
24
22
  return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
25
23
  })()`);
26
24
  if (!ct0)
27
25
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
28
- // Try legacy guide.json API first (faster than DOM scraping)
29
- let trends = [];
30
- const apiData = await page.evaluate(`(async () => {
31
- const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || '';
32
- const r = await fetch('/i/api/2/guide.json?include_page_configuration=true', {
33
- credentials: 'include',
34
- headers: {
35
- 'x-twitter-active-user': 'yes',
36
- 'x-csrf-token': ct0,
37
- 'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'
26
+ // Scrape trends from DOM (consistent with what the user sees on the page)
27
+ // DOM children: [0] rank + category, [1] topic, optional post count,
28
+ // and a caret menu button identified by [data-testid="caret"].
29
+ await page.wait(2);
30
+ const trends = await page.evaluate(`(() => {
31
+ const items = [];
32
+ const cells = document.querySelectorAll('[data-testid="trend"]');
33
+ cells.forEach((cell) => {
34
+ const text = cell.textContent || '';
35
+ if (text.includes('Promoted')) return;
36
+ const container = cell.querySelector(':scope > div');
37
+ if (!container) return;
38
+ const divs = container.children;
39
+ if (divs.length < 2) return;
40
+ const topic = divs[1].textContent.trim();
41
+ if (!topic) return;
42
+ const catText = divs[0].textContent.trim();
43
+ const category = catText.replace(/^\\d+\\s*/, '').replace(/^\\xB7\\s*/, '').trim();
44
+ // Find post count: skip rank, topic, and the caret menu button
45
+ let tweets = 'N/A';
46
+ for (let j = 2; j < divs.length; j++) {
47
+ if (divs[j].matches('[data-testid="caret"]') || divs[j].querySelector('[data-testid="caret"]')) continue;
48
+ const t = divs[j].textContent.trim();
49
+ if (t && /\\d/.test(t)) { tweets = t; break; }
38
50
  }
51
+ items.push({ rank: items.length + 1, topic, tweets, category });
39
52
  });
40
- return r.ok ? await r.json() : null;
53
+ return items;
41
54
  })()`);
42
- if (apiData) {
43
- const instructions = apiData?.timeline?.instructions || [];
44
- const entries = instructions.flatMap((inst) => inst?.addEntries?.entries || inst?.entries || []);
45
- const apiTrends = entries
46
- .filter((e) => e.content?.timelineModule)
47
- .flatMap((e) => e.content.timelineModule.items || [])
48
- .map((t) => t?.item?.content?.trend)
49
- .filter(Boolean);
50
- trends = apiTrends.map((t, i) => ({
51
- rank: i + 1,
52
- topic: t.name,
53
- tweets: t.tweetCount ? String(t.tweetCount) : 'N/A',
54
- category: t.trendMetadata?.domainContext || '',
55
- }));
56
- }
57
- // Fallback: scrape from the loaded DOM
58
- if (trends.length === 0) {
59
- await page.wait(2);
60
- const domTrends = await page.evaluate(`(() => {
61
- const items = [];
62
- const cells = document.querySelectorAll('[data-testid="trend"]');
63
- cells.forEach((cell) => {
64
- const text = cell.textContent || '';
65
- if (text.includes('Promoted')) return;
66
- const container = cell.querySelector(':scope > div');
67
- if (!container) return;
68
- const divs = container.children;
69
- // Structure: divs[0] = rank + category, divs[1] = topic name, divs[2] = extra
70
- const topicEl = divs.length >= 2 ? divs[1] : null;
71
- const topic = topicEl ? topicEl.textContent.trim() : '';
72
- const catEl = divs.length >= 1 ? divs[0] : null;
73
- const catText = catEl ? catEl.textContent.trim() : '';
74
- const category = catText.replace(/^\\d+\\s*/, '').replace(/^\\xB7\\s*/, '').trim();
75
- const extraEl = divs.length >= 3 ? divs[2] : null;
76
- const extra = extraEl ? extraEl.textContent.trim() : '';
77
- if (topic) {
78
- items.push({ rank: items.length + 1, topic, tweets: extra || 'N/A', category });
79
- }
80
- });
81
- return items;
82
- })()`);
83
- if (Array.isArray(domTrends) && domTrends.length > 0) {
84
- trends = domTrends;
85
- }
86
- }
87
- if (trends.length === 0) {
88
- throw new EmptyResultError('twitter trending', 'API may have changed or login may be required.');
55
+ if (!Array.isArray(trends) || trends.length === 0) {
56
+ throw new EmptyResultError('twitter trending', 'No trends found. The page structure may have changed.');
89
57
  }
90
58
  return trends.slice(0, limit);
91
59
  },