@jackwener/opencli 1.5.0 → 1.5.1

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 (79) hide show
  1. package/dist/browser/cdp.js +5 -0
  2. package/dist/browser/page.d.ts +3 -0
  3. package/dist/browser/page.js +24 -1
  4. package/dist/cli-manifest.json +465 -5
  5. package/dist/cli.js +34 -3
  6. package/dist/clis/bluesky/feeds.yaml +29 -0
  7. package/dist/clis/bluesky/followers.yaml +33 -0
  8. package/dist/clis/bluesky/following.yaml +33 -0
  9. package/dist/clis/bluesky/profile.yaml +27 -0
  10. package/dist/clis/bluesky/search.yaml +34 -0
  11. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  12. package/dist/clis/bluesky/thread.yaml +32 -0
  13. package/dist/clis/bluesky/trending.yaml +27 -0
  14. package/dist/clis/bluesky/user.yaml +34 -0
  15. package/dist/clis/twitter/trending.js +29 -61
  16. package/dist/clis/v2ex/hot.yaml +17 -3
  17. package/dist/clis/xiaohongshu/publish.js +78 -42
  18. package/dist/clis/xiaohongshu/publish.test.js +20 -8
  19. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  20. package/dist/clis/xiaohongshu/search.js +20 -1
  21. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  22. package/dist/clis/xiaohongshu/search.test.js +32 -1
  23. package/dist/discovery.js +40 -28
  24. package/dist/doctor.d.ts +1 -2
  25. package/dist/doctor.js +2 -2
  26. package/dist/engine.test.js +42 -0
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/errors.js +2 -2
  29. package/dist/execution.js +45 -7
  30. package/dist/execution.test.d.ts +1 -0
  31. package/dist/execution.test.js +40 -0
  32. package/dist/external.js +6 -1
  33. package/dist/main.js +1 -0
  34. package/dist/plugin-scaffold.d.ts +28 -0
  35. package/dist/plugin-scaffold.js +142 -0
  36. package/dist/plugin-scaffold.test.d.ts +4 -0
  37. package/dist/plugin-scaffold.test.js +83 -0
  38. package/dist/plugin.d.ts +55 -17
  39. package/dist/plugin.js +706 -154
  40. package/dist/plugin.test.js +836 -38
  41. package/dist/runtime.d.ts +1 -0
  42. package/dist/runtime.js +1 -1
  43. package/dist/types.d.ts +2 -0
  44. package/docs/adapters/browser/bluesky.md +53 -0
  45. package/docs/guide/plugins.md +10 -0
  46. package/package.json +1 -1
  47. package/src/browser/cdp.ts +6 -0
  48. package/src/browser/page.ts +24 -1
  49. package/src/cli.ts +34 -3
  50. package/src/clis/bluesky/feeds.yaml +29 -0
  51. package/src/clis/bluesky/followers.yaml +33 -0
  52. package/src/clis/bluesky/following.yaml +33 -0
  53. package/src/clis/bluesky/profile.yaml +27 -0
  54. package/src/clis/bluesky/search.yaml +34 -0
  55. package/src/clis/bluesky/starter-packs.yaml +34 -0
  56. package/src/clis/bluesky/thread.yaml +32 -0
  57. package/src/clis/bluesky/trending.yaml +27 -0
  58. package/src/clis/bluesky/user.yaml +34 -0
  59. package/src/clis/twitter/trending.ts +29 -77
  60. package/src/clis/v2ex/hot.yaml +17 -3
  61. package/src/clis/xiaohongshu/publish.test.ts +22 -8
  62. package/src/clis/xiaohongshu/publish.ts +93 -52
  63. package/src/clis/xiaohongshu/search.test.ts +39 -1
  64. package/src/clis/xiaohongshu/search.ts +19 -1
  65. package/src/discovery.ts +41 -33
  66. package/src/doctor.ts +2 -3
  67. package/src/engine.test.ts +38 -0
  68. package/src/errors.ts +6 -2
  69. package/src/execution.test.ts +47 -0
  70. package/src/execution.ts +39 -6
  71. package/src/external.ts +6 -1
  72. package/src/main.ts +1 -0
  73. package/src/plugin-scaffold.test.ts +98 -0
  74. package/src/plugin-scaffold.ts +170 -0
  75. package/src/plugin.test.ts +881 -38
  76. package/src/plugin.ts +871 -158
  77. package/src/runtime.ts +2 -2
  78. package/src/types.ts +2 -0
  79. package/tests/e2e/browser-public.test.ts +1 -1
package/dist/runtime.d.ts CHANGED
@@ -13,6 +13,7 @@ export declare const DEFAULT_BROWSER_EXPLORE_TIMEOUT: number;
13
13
  export declare function runWithTimeout<T>(promise: Promise<T>, opts: {
14
14
  timeout: number;
15
15
  label?: string;
16
+ hint?: string;
16
17
  }): Promise<T>;
17
18
  /**
18
19
  * Timeout with milliseconds unit. Used for low-level internal timeouts.
package/dist/runtime.js CHANGED
@@ -26,7 +26,7 @@ export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_
26
26
  */
27
27
  export async function runWithTimeout(promise, opts) {
28
28
  const label = opts.label ?? 'Operation';
29
- return withTimeoutMs(promise, opts.timeout * 1000, () => new TimeoutError(label, opts.timeout));
29
+ return withTimeoutMs(promise, opts.timeout * 1000, () => new TimeoutError(label, opts.timeout, opts.hint));
30
30
  }
31
31
  /**
32
32
  * Timeout with milliseconds unit. Used for low-level internal timeouts.
package/dist/types.d.ts CHANGED
@@ -69,4 +69,6 @@ export interface IPage {
69
69
  getInterceptedRequests(): Promise<any[]>;
70
70
  screenshot(options?: ScreenshotOptions): Promise<string>;
71
71
  closeWindow?(): Promise<void>;
72
+ /** Returns the current page URL, or null if unavailable. */
73
+ getCurrentUrl?(): Promise<string | null>;
72
74
  }
@@ -0,0 +1,53 @@
1
+ # Bluesky
2
+
3
+ **Mode**: 🌐 Public · **Domain**: `bsky.app`
4
+
5
+ ## Commands
6
+
7
+ | Command | Description |
8
+ |---------|-------------|
9
+ | `opencli bluesky profile` | User profile info |
10
+ | `opencli bluesky user` | Recent posts from a user |
11
+ | `opencli bluesky trending` | Trending topics |
12
+ | `opencli bluesky search` | Search users |
13
+ | `opencli bluesky feeds` | Popular feed generators |
14
+ | `opencli bluesky followers` | User's followers |
15
+ | `opencli bluesky following` | Accounts a user follows |
16
+ | `opencli bluesky thread` | Post thread with replies |
17
+ | `opencli bluesky starter-packs` | User's starter packs |
18
+
19
+ ## Usage Examples
20
+
21
+ ```bash
22
+ # User profile
23
+ opencli bluesky profile --handle bsky.app
24
+
25
+ # Recent posts
26
+ opencli bluesky user --handle bsky.app --limit 10
27
+
28
+ # Trending topics
29
+ opencli bluesky trending --limit 10
30
+
31
+ # Search users
32
+ opencli bluesky search --query "AI" --limit 10
33
+
34
+ # Popular feeds
35
+ opencli bluesky feeds --limit 10
36
+
37
+ # Followers / following
38
+ opencli bluesky followers --handle bsky.app --limit 10
39
+ opencli bluesky following --handle bsky.app
40
+
41
+ # Post thread with replies
42
+ opencli bluesky thread --uri "at://did:.../app.bsky.feed.post/..."
43
+
44
+ # Starter packs
45
+ opencli bluesky starter-packs --handle bsky.app
46
+
47
+ # JSON output
48
+ opencli bluesky profile --handle bsky.app -f json
49
+ ```
50
+
51
+ ## Prerequisites
52
+
53
+ None — all commands use the public Bluesky AT Protocol API, no browser or login required.
@@ -31,9 +31,19 @@ Plugins live in `~/.opencli/plugins/<name>/`. Each subdirectory is scanned at st
31
31
  ### Supported Source Formats
32
32
 
33
33
  ```bash
34
+ # GitHub shorthand
34
35
  opencli plugin install github:user/repo
35
36
  opencli plugin install github:user/repo/subplugin # install specific sub-plugin from monorepo
36
37
  opencli plugin install https://github.com/user/repo
38
+
39
+ # Any git-cloneable URL
40
+ opencli plugin install https://gitlab.example.com/team/repo.git
41
+ opencli plugin install ssh://git@gitlab.example.com/team/repo.git
42
+ opencli plugin install git@gitlab.example.com:team/repo.git
43
+
44
+ # Local plugin (for development)
45
+ opencli plugin install file:///path/to/plugin
46
+ opencli plugin install /path/to/plugin
37
47
  ```
38
48
 
39
49
  The repo name prefix `opencli-plugin-` is automatically stripped for the local directory name. For example, `opencli-plugin-hot-digest` becomes `hot-digest`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -173,6 +173,7 @@ export class CDPBridge implements IBrowserFactory {
173
173
 
174
174
  class CDPPage implements IPage {
175
175
  private _pageEnabled = false;
176
+ private _lastUrl: string | null = null;
176
177
  constructor(private bridge: CDPBridge) {}
177
178
 
178
179
  async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
@@ -183,6 +184,7 @@ class CDPPage implements IPage {
183
184
  const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000).catch(() => {});
184
185
  await this.bridge.send('Page.navigate', { url });
185
186
  await loadPromise;
187
+ this._lastUrl = url;
186
188
  if (options?.waitUntil !== 'none') {
187
189
  const maxMs = options?.settleMs ?? 1000;
188
190
  await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
@@ -307,6 +309,10 @@ class CDPPage implements IPage {
307
309
  return [];
308
310
  }
309
311
 
312
+ async getCurrentUrl(): Promise<string | null> {
313
+ return this._lastUrl;
314
+ }
315
+
310
316
  async installInterceptor(pattern: string): Promise<void> {
311
317
  const { generateInterceptorJs } = await import('../interceptor.js');
312
318
  await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
@@ -36,6 +36,8 @@ export class Page implements IPage {
36
36
 
37
37
  /** Active tab ID, set after navigate and used in all subsequent commands */
38
38
  private _tabId: number | undefined;
39
+ /** Last navigated URL, tracked in-memory to avoid extra round-trips */
40
+ private _lastUrl: string | null = null;
39
41
 
40
42
  /** Helper: spread workspace into command params */
41
43
  private _wsOpt(): { workspace: string } {
@@ -55,10 +57,11 @@ export class Page implements IPage {
55
57
  url,
56
58
  ...this._cmdOpts(),
57
59
  }) as { tabId?: number };
58
- // Remember the tabId for subsequent exec calls
60
+ // Remember the tabId and URL for subsequent calls
59
61
  if (result?.tabId) {
60
62
  this._tabId = result.tabId;
61
63
  }
64
+ this._lastUrl = url;
62
65
  // Inject stealth anti-detection patches (guard flag prevents double-injection).
63
66
  try {
64
67
  await sendCommand('exec', {
@@ -79,6 +82,10 @@ export class Page implements IPage {
79
82
  }
80
83
  }
81
84
 
85
+ async getCurrentUrl(): Promise<string | null> {
86
+ return this._lastUrl;
87
+ }
88
+
82
89
  /** Close the automation window in the extension */
83
90
  async closeWindow(): Promise<void> {
84
91
  try {
@@ -183,6 +190,22 @@ export class Page implements IPage {
183
190
 
184
191
  async wait(options: number | WaitOptions): Promise<void> {
185
192
  if (typeof options === 'number') {
193
+ if (options >= 1) {
194
+ // For waits >= 1s, use DOM-stable check: return early when the page
195
+ // stops mutating, with the original wait time as the hard cap.
196
+ // This turns e.g. `page.wait(5)` from a fixed 5s sleep into
197
+ // "wait until DOM is stable, max 5s" — often completing in <1s.
198
+ try {
199
+ const maxMs = options * 1000;
200
+ await sendCommand('exec', {
201
+ code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
202
+ ...this._cmdOpts(),
203
+ });
204
+ return;
205
+ } catch {
206
+ // Fallback: fixed sleep (e.g. if page has no DOM yet)
207
+ }
208
+ }
186
209
  await new Promise(resolve => setTimeout(resolve, options * 1000));
187
210
  return;
188
211
  }
package/src/cli.ts CHANGED
@@ -76,9 +76,10 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
76
76
  for (const [site, cmds] of sites) {
77
77
  console.log(chalk.bold.cyan(` ${site}`));
78
78
  for (const cmd of cmds) {
79
- const tag = strategyLabel(cmd) === 'public'
79
+ const label = strategyLabel(cmd);
80
+ const tag = label === 'public'
80
81
  ? chalk.green('[public]')
81
- : chalk.yellow(`[${strategyLabel(cmd)}]`);
82
+ : chalk.yellow(`[${label}]`);
82
83
  console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`);
83
84
  }
84
85
  console.log();
@@ -252,7 +253,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
252
253
 
253
254
  pluginCmd
254
255
  .command('install')
255
- .description('Install a plugin from GitHub')
256
+ .description('Install a plugin from a git repository')
256
257
  .argument('<source>', 'Plugin source (e.g. github:user/repo)')
257
258
  .action(async (source: string) => {
258
259
  const { installPlugin } = await import('./plugin.js');
@@ -411,6 +412,36 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
411
412
  console.log();
412
413
  });
413
414
 
415
+ pluginCmd
416
+ .command('create')
417
+ .description('Create a new plugin scaffold')
418
+ .argument('<name>', 'Plugin name (lowercase, hyphens allowed)')
419
+ .option('-d, --dir <path>', 'Output directory (default: ./<name>)')
420
+ .option('--description <text>', 'Plugin description')
421
+ .action(async (name: string, opts: { dir?: string; description?: string }) => {
422
+ const { createPluginScaffold } = await import('./plugin-scaffold.js');
423
+ try {
424
+ const result = createPluginScaffold(name, {
425
+ dir: opts.dir,
426
+ description: opts.description,
427
+ });
428
+ console.log(chalk.green(`✅ Plugin scaffold created at ${result.dir}`));
429
+ console.log();
430
+ console.log(chalk.bold(' Files created:'));
431
+ for (const f of result.files) {
432
+ console.log(` ${chalk.cyan(f)}`);
433
+ }
434
+ console.log();
435
+ console.log(chalk.dim(' Next steps:'));
436
+ console.log(chalk.dim(` cd ${result.dir}`));
437
+ console.log(chalk.dim(` opencli plugin install file://${result.dir}`));
438
+ console.log(chalk.dim(` opencli ${name} hello`));
439
+ } catch (err) {
440
+ console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
441
+ process.exitCode = 1;
442
+ }
443
+ });
444
+
414
445
  // ── External CLIs ─────────────────────────────────────────────────────────
415
446
 
416
447
  const externalClis = loadExternalClis();
@@ -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);