A TypeScript toolkit for consuming the Bluesky network in real-time.
1// Search Posts
2// Searches Bluesky posts with rich filtering, then displays engagement stats.
3// Requires a Bluesky auth token (the searchPosts endpoint is not public).
4// Demonstrates: searchPosts, getLikes, getRepostedBy, getQuotes, toBskyUrl.
5//
6// Setup:
7// export BSKY_AUTH_TOKEN="your-access-jwt-here"
8//
9// Usage: npx tsx examples/search-posts.ts <query> [options]
10// Examples:
11// npx tsx examples/search-posts.ts "bluesky"
12// npx tsx examples/search-posts.ts "typescript" --sort=latest --lang=en --limit=5
13// npx tsx examples/search-posts.ts "atproto" --author=jay.bsky.team
14// npx tsx examples/search-posts.ts "bluesky" --tag=tech --tag=social
15
16import {
17 getBskyAuthToken,
18 fetchSearchPosts,
19 fetchGetLikes,
20 fetchGetRepostedBy,
21 fetchGetQuotes,
22 extractPostViewInfo,
23 formatTruncated,
24 formatEngagement,
25} from "../lib/index.js";
26import type { SearchPostsOptions } from "../lib/index.js";
27
28// --- Config ---
29
30const args = process.argv.slice(2);
31const query = args.find((a) => !a.startsWith("--"));
32
33if (!query) {
34 console.error("Usage: npx tsx examples/search-posts.ts <query> [options]");
35 console.error("");
36 console.error("Options:");
37 console.error(" --sort=top|latest Sort order (default: top)");
38 console.error(" --lang=<code> Language filter (e.g. en, ja)");
39 console.error(" --author=<handle> Filter by author");
40 console.error(" --limit=<n> Number of results (default: 10)");
41 console.error(" --tag=<tag> Filter by tag (can repeat)");
42 console.error(" --since=<datetime> Posts after this time");
43 console.error(" --until=<datetime> Posts before this time");
44 console.error(" --engagement Fetch detailed engagement for each post");
45 console.error("");
46 console.error("Requires BSKY_AUTH_TOKEN env var (see README for details).");
47 process.exit(1);
48}
49
50if (!getBskyAuthToken()) {
51 console.error("Missing BSKY_AUTH_TOKEN environment variable.\n");
52 console.error("The searchPosts endpoint requires authentication.");
53 console.error("To get a token, create an App Password at:");
54 console.error(" https://bsky.app/settings/app-passwords\n");
55 console.error("Then create a session to get an access JWT:");
56 console.error(` curl -s -X POST https://bsky.social/xrpc/com.atproto.server.createSession \\`);
57 console.error(` -H "Content-Type: application/json" \\`);
58 console.error(` -d '{"identifier":"your.handle","password":"your-app-password"}' \\`);
59 console.error(` | jq -r .accessJwt\n`);
60 console.error("Then export it:");
61 console.error(" export BSKY_AUTH_TOKEN=\"eyJ...\"\n");
62 process.exit(1);
63}
64
65const getFlag = (name: string): string | undefined => {
66 const arg = args.find((a) => a.startsWith(`--${name}=`));
67 return arg?.split("=").slice(1).join("=");
68};
69
70const hasFlag = (name: string): boolean =>
71 args.includes(`--${name}`);
72
73const getTags = (): string[] =>
74 args
75 .filter((a) => a.startsWith("--tag="))
76 .map((a) => a.split("=").slice(1).join("="));
77
78const options: SearchPostsOptions = {
79 sort: (getFlag("sort") as "top" | "latest") ?? undefined,
80 lang: getFlag("lang"),
81 author: getFlag("author"),
82 limit: getFlag("limit") ? Number(getFlag("limit")) : 10,
83 tag: getTags().length ? getTags() : undefined,
84 since: getFlag("since"),
85 until: getFlag("until"),
86};
87
88const showEngagement = hasFlag("engagement");
89
90// --- Helpers ---
91
92const divider = () => console.log("─".repeat(60));
93
94// --- Main ---
95
96const main = async () => {
97 console.log(`\nSearching: "${query}"\n`);
98
99 try {
100 const data = await fetchSearchPosts(query, options);
101 const posts = data.posts ?? [];
102
103 if (posts.length === 0) {
104 console.log("No posts found.\n");
105 return;
106 }
107
108 console.log(`Found ${posts.length} posts:\n`);
109 divider();
110
111 for (const post of posts) {
112 const info = extractPostViewInfo(post);
113
114 console.log(` ${info.displayName} (@${info.handle})`);
115 console.log(` ${formatTruncated(info.text, 200)}`);
116 console.log(` ${formatEngagement({ likeCount: post.likeCount, repostCount: post.repostCount, quoteCount: post.quoteCount, replyCount: post.replyCount })}`);
117 console.log(` ${info.bskyUrl}`);
118
119 if (showEngagement && post.uri) {
120 try {
121 const [likes, reposts, quotes] = await Promise.all([
122 fetchGetLikes(post.uri, { limit: 3 }),
123 fetchGetRepostedBy(post.uri, { limit: 3 }),
124 fetchGetQuotes(post.uri, { limit: 3 }),
125 ]);
126
127 const likeNames = (likes.likes ?? [])
128 .map((l: any) => `@${l.actor.handle}`)
129 .join(", ");
130 const repostNames = (reposts.repostedBy ?? [])
131 .map((r: any) => `@${r.handle}`)
132 .join(", ");
133 const quoteTexts = (quotes.posts ?? [])
134 .map((q: any) => {
135 const qt = (q.record as any)?.text ?? "";
136 return formatTruncated(qt, 50);
137 });
138
139 if (likeNames) console.log(` Liked by: ${likeNames}`);
140 if (repostNames) console.log(` Reposted by: ${repostNames}`);
141 if (quoteTexts.length) {
142 for (const qt of quoteTexts) {
143 console.log(` Quoted: "${qt}"`);
144 }
145 }
146 } catch (err: any) {
147 console.log(` (engagement fetch failed: ${err.message})`);
148 }
149 }
150
151 divider();
152 }
153
154 if (data.cursor) {
155 console.log(`\n (more results available, cursor: ${data.cursor})\n`);
156 }
157 } catch (err: any) {
158 console.error(`Error: ${err.message}`);
159 process.exit(1);
160 }
161};
162
163main();