Source code of my website
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

✨ : add script to fetch bluesky post stats

+207
+6
mise.toml
··· 17 17 tools.go = "1.21" 18 18 run = "go run plausible_fetch.go -site codeka.io -range all -out ../data/plausible-stats/visits.json" 19 19 20 + [tasks.fetch-bluesky-stats] 21 + description = 'Fetch data from bluesky' 22 + dir = "scripts" 23 + tools.go = "1.21" 24 + run = "go run bluesky_stats.go" 25 + 20 26 [tasks.build] 21 27 description = "Build le site avec Hugo" 22 28 depends = ['fetch-views-data']
+201
scripts/bluesky_stats.go
··· 1 + // bluesky_stats.go is a script to fetch the number of likes, quotes, and reposts for a Bluesky post. 2 + // It uses the atproto SDK (github.com/bluesky-social/indigo). 3 + // 4 + // Usage: 5 + // 6 + // cd scripts 7 + // go run bluesky_stats.go <post_url_or_at_uri> 8 + // 9 + // Example: 10 + // 11 + // go run bluesky_stats.go https://bsky.app/profile/bsky.app/post/3lbvlb537322q 12 + package main 13 + 14 + import ( 15 + "bufio" 16 + "context" 17 + "encoding/json" 18 + "fmt" 19 + "log" 20 + "os" 21 + "path/filepath" 22 + "strings" 23 + 24 + "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/api/bsky" 26 + "github.com/bluesky-social/indigo/xrpc" 27 + ) 28 + 29 + type BlueskyStats struct { 30 + URI string `json:"uri"` 31 + Likes int64 `json:"likes"` 32 + Reposts int64 `json:"reposts"` 33 + Quotes int64 `json:"quotes"` 34 + Replies int64 `json:"replies"` 35 + } 36 + 37 + type BlogPost struct { 38 + FilePath string 39 + BlueskyPostURL string 40 + } 41 + 42 + func main() { 43 + postsDir := "../content/posts" 44 + 45 + ctx := context.Background() 46 + client := &xrpc.Client{ 47 + Host: "https://public.api.bsky.app", 48 + } 49 + 50 + // Scan all posts mode 51 + posts, err := findPostsWithBlueskyRefs(postsDir) 52 + if err != nil { 53 + log.Fatalf("Error scanning posts: %v", err) 54 + } 55 + 56 + if len(posts) == 0 { 57 + fmt.Println("No posts with 'bluesky' attribute found.") 58 + return 59 + } 60 + 61 + fmt.Printf("Found %d posts with Bluesky URLs\n", len(posts)) 62 + allStats := make(map[string]BlueskyStats) 63 + 64 + for _, p := range posts { 65 + fmt.Printf("Fetching stats for %s...\n", p.BlueskyPostURL) 66 + stats, err := fetchStats(ctx, client, p.BlueskyPostURL) 67 + if err != nil { 68 + fmt.Printf("Warning: failed to fetch stats for %s: %v\n", p.BlueskyPostURL, err) 69 + continue 70 + } 71 + 72 + // Write individual JSON file in the post directory 73 + outPath := filepath.Join(filepath.Dir(p.FilePath), "bluesky-stats.json") 74 + if err := writeJSON(outPath, stats); err != nil { 75 + fmt.Printf("Warning: failed to write JSON to %s: %v\n", outPath, err) 76 + } else { 77 + fmt.Printf("Wrote stats to %s\n", outPath) 78 + } 79 + 80 + allStats[p.BlueskyPostURL] = *stats 81 + } 82 + } 83 + 84 + func writeJSON(path string, data interface{}) error { 85 + f, err := os.Create(path) 86 + if err != nil { 87 + return err 88 + } 89 + defer f.Close() 90 + 91 + enc := json.NewEncoder(f) 92 + enc.SetIndent("", " ") 93 + return enc.Encode(data) 94 + } 95 + 96 + func fetchStats(ctx context.Context, client *xrpc.Client, input string) (*BlueskyStats, error) { 97 + atUri := input 98 + if strings.HasPrefix(input, "https://bsky.app/profile/") { 99 + atUri = urlToAtUri(ctx, client, input) 100 + } 101 + 102 + // FeedGetPostThread returns the post and its replies/parents. 103 + // depth=0 and parentHeight=0 to just get the post itself. 104 + res, err := bsky.FeedGetPostThread(ctx, client, 0, 0, atUri) 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get post thread: %w", err) 107 + } 108 + 109 + if res.Thread.FeedDefs_ThreadViewPost == nil { 110 + return nil, fmt.Errorf("post not found or inaccessible (type: %T)", res.Thread) 111 + } 112 + 113 + post := res.Thread.FeedDefs_ThreadViewPost.Post 114 + 115 + stats := &BlueskyStats{ 116 + URI: atUri, 117 + } 118 + if post.LikeCount != nil { 119 + stats.Likes = *post.LikeCount 120 + } 121 + if post.RepostCount != nil { 122 + stats.Reposts = *post.RepostCount 123 + } 124 + if post.QuoteCount != nil { 125 + stats.Quotes = *post.QuoteCount 126 + } 127 + if post.ReplyCount != nil { 128 + stats.Replies = *post.ReplyCount 129 + } 130 + return stats, nil 131 + } 132 + 133 + func findPostsWithBlueskyRefs(root string) ([]BlogPost, error) { 134 + var posts []BlogPost 135 + uniqueURLs := make(map[string]bool) 136 + 137 + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 138 + if err != nil { 139 + return err 140 + } 141 + if info.IsDir() || !strings.HasSuffix(path, ".md") { 142 + return nil 143 + } 144 + 145 + file, err := os.Open(path) 146 + if err != nil { 147 + return err 148 + } 149 + defer file.Close() 150 + 151 + scanner := bufio.NewScanner(file) 152 + inFrontMatter := false 153 + for scanner.Scan() { 154 + line := scanner.Text() 155 + if line == "---" { 156 + if !inFrontMatter { 157 + inFrontMatter = true 158 + continue 159 + } else { 160 + break // end of frontmatter 161 + } 162 + } 163 + if inFrontMatter { 164 + if strings.HasPrefix(line, "bluesky:") { 165 + url := strings.TrimSpace(strings.TrimPrefix(line, "bluesky:")) 166 + if url != "" && !uniqueURLs[url] { 167 + posts = append(posts, BlogPost{FilePath: path, BlueskyPostURL: url}) 168 + uniqueURLs[url] = true 169 + } 170 + } 171 + } 172 + } 173 + return scanner.Err() 174 + }) 175 + return posts, err 176 + } 177 + 178 + func urlToAtUri(ctx context.Context, client *xrpc.Client, url string) string { 179 + // Format: https://bsky.app/profile/<handle>/post/<postId> 180 + trimmed := strings.TrimPrefix(url, "https://bsky.app/profile/") 181 + parts := strings.Split(trimmed, "/") 182 + if len(parts) < 3 || parts[1] != "post" { 183 + log.Fatalf("Error: invalid Bluesky post BlueskyPostURL format: %s", url) 184 + } 185 + 186 + handle := parts[0] 187 + postId := parts[2] 188 + 189 + // If handle is already a DID, use it. 190 + if strings.HasPrefix(handle, "did:") { 191 + return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", handle, postId) 192 + } 193 + 194 + // Resolve handle to DID 195 + res, err := atproto.IdentityResolveHandle(ctx, client, handle) 196 + if err != nil { 197 + log.Fatalf("Error: failed to resolve handle %s: %v", handle, err) 198 + } 199 + 200 + return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", res.Did, postId) 201 + }