A TypeScript toolkit for consuming the Bluesky network in real-time.
at main 163 lines 5.6 kB view raw
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();