Created
May 24, 2025 11:59
-
-
Save hahanein/f1d4ad984b4932d1c1fa26900e6fcd8e to your computer and use it in GitHub Desktop.
Stream and grep a Bluesky user's feed for posts matching a regex
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Command bluesky authenticates against the atproto service, walks an | |
| // author's feed in *streaming* fashion, and prints every post whose text | |
| // matches a user‑supplied regular expression. | |
| // | |
| // Flags | |
| // | |
| // -username your Bluesky handle (required) | |
| // -password your Bluesky password (required) | |
| // -other the handle whose feed you want to inspect (required) | |
| // -pattern a regular expression for matching post text (default "(?i)golang") | |
| // | |
| // Example: | |
| // | |
| // go run bluesky.go \ | |
| // -username alice \ | |
| // -password my-secret-app-pass \ | |
| // -other bob \ | |
| // -pattern '(?i)golang|go' | |
| package main | |
| import ( | |
| "bytes" | |
| "encoding/json" | |
| "flag" | |
| "fmt" | |
| "io" | |
| "log" | |
| "net/http" | |
| "regexp" | |
| "strings" | |
| "time" | |
| ) | |
| const ( | |
| sessionURL = "https://bsky.social/xrpc/com.atproto.server.createSession" | |
| feedURL = "https://bsky.social/xrpc/app.bsky.feed.getAuthorFeed?actor=" | |
| appURL = "https://bsky.app/profile/" | |
| ) | |
| // AuthResponse is the JSON envelope returned by the createSession endpoint. | |
| type AuthResponse struct { | |
| AccessJWT string `json:"accessJwt"` | |
| DID string `json:"did"` | |
| Handle string `json:"handle"` | |
| } | |
| // Post represents a single post in an author feed. | |
| type Post struct { | |
| URI string `json:"uri"` | |
| CID string `json:"cid"` | |
| Author struct { | |
| DID string `json:"did"` | |
| Handle string `json:"handle"` | |
| DisplayName string `json:"displayName"` | |
| } `json:"author"` | |
| Record struct { | |
| Text string `json:"text"` | |
| CreatedAt string `json:"createdAt"` | |
| } `json:"record"` | |
| } | |
| // feedResponse mirrors the on‑wire shape of app.bsky.feed.getAuthorFeed. | |
| // It is unexported because callers only care about the extracted Post values. | |
| type feedResponse struct { | |
| Feed []struct{ Post Post } `json:"feed"` | |
| Cursor string `json:"cursor"` | |
| } | |
| // fetchToken logs in and returns a short‑lived JWT for subsequent requests. | |
| func fetchToken(user, pass string) (string, error) { | |
| body, _ := json.Marshal(map[string]string{ | |
| "identifier": user, | |
| "password": pass, | |
| }) | |
| resp, err := http.Post(sessionURL, "application/json", bytes.NewReader(body)) | |
| if err != nil { | |
| return "", err | |
| } | |
| defer resp.Body.Close() | |
| if resp.StatusCode != http.StatusOK { | |
| b, _ := io.ReadAll(resp.Body) | |
| return "", fmt.Errorf("login failed: %s", b) | |
| } | |
| var ar AuthResponse | |
| if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil { | |
| return "", err | |
| } | |
| if ar.AccessJWT == "" { | |
| return "", fmt.Errorf("empty JWT") | |
| } | |
| return ar.AccessJWT, nil | |
| } | |
| // streamPosts fetches an author's feed page-by-page and pushes each Post onto | |
| // the channel as soon as it is decoded. It signals completion (or early | |
| // error) by sending exactly one value on the done channel before returning. | |
| func streamPosts(actor, jwt string, posts chan<- Post, done chan<- error) { | |
| defer close(posts) | |
| defer close(done) | |
| client := &http.Client{Timeout: 10 * time.Second} | |
| cursor := "" | |
| for { | |
| url := feedURL + actor | |
| if cursor != "" { | |
| url += "&cursor=" + cursor | |
| } | |
| req, err := http.NewRequest(http.MethodGet, url, nil) | |
| if err != nil { | |
| done <- err | |
| return | |
| } | |
| req.Header.Set("Authorization", "Bearer "+jwt) | |
| resp, err := client.Do(req) | |
| if err != nil { | |
| done <- err | |
| return | |
| } | |
| body, err := io.ReadAll(resp.Body) | |
| resp.Body.Close() | |
| if err != nil { | |
| done <- err | |
| return | |
| } | |
| if resp.StatusCode != http.StatusOK { | |
| done <- fmt.Errorf("feed error: %s", body) | |
| return | |
| } | |
| var f feedResponse | |
| if err := json.Unmarshal(body, &f); err != nil { | |
| done <- err | |
| return | |
| } | |
| for _, item := range f.Feed { | |
| posts <- item.Post | |
| } | |
| if f.Cursor == "" { | |
| break | |
| } | |
| cursor = f.Cursor | |
| } | |
| done <- nil | |
| } | |
| // rkey extracts the record key (the final path element) from a bsky URI. | |
| func rkey(uri string) string { | |
| parts := strings.Split(uri, "/") | |
| return parts[len(parts)-1] | |
| } | |
| func main() { | |
| user := flag.String("username", "", "Bluesky handle (required)") | |
| pass := flag.String("password", "", "Bluesky password (required)") | |
| handle := flag.String("other", "", "Bluesky handle whose feed to inspect (required)") | |
| pattern := flag.String("pattern", "(?i)golang", "regular expression to match post text") | |
| flag.Parse() | |
| if *user == "" || *pass == "" || *handle == "" { | |
| flag.Usage() | |
| log.Fatal("username, password, and other are required") | |
| } | |
| re, err := regexp.Compile(*pattern) | |
| if err != nil { | |
| log.Fatalf("bad pattern: %v", err) | |
| } | |
| jwt, err := fetchToken(*user, *pass) | |
| if err != nil { | |
| log.Fatalf("login: %v", err) | |
| } | |
| posts := make(chan Post, 100) | |
| done := make(chan error, 1) | |
| go streamPosts(*handle, jwt, posts, done) | |
| for { | |
| select { | |
| case p, ok := <-posts: | |
| if !ok { | |
| posts = nil // prevent further selects on closed chan | |
| continue | |
| } | |
| if re.MatchString(p.Record.Text) { | |
| url := fmt.Sprintf("%s%s/post/%s", appURL, p.Author.Handle, rkey(p.URI)) | |
| fmt.Printf("url: %s\ncreatedAt: %s\nauthor: %s\ntext: %s\n---\n", | |
| url, p.Record.CreatedAt, p.Author.Handle, p.Record.Text) | |
| } | |
| case err := <-done: | |
| if err != nil { | |
| log.Fatalf("fetch: %v", err) | |
| } | |
| if posts == nil { // posts already drained | |
| return | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment