Thread viewer for Bluesky
at master 210 lines 5.9 kB view raw
1import { atURI, feedPostTime } from '../utils.js'; 2import { BlueskyAPI, accountAPI } from '../api.js'; 3 4export type LikeStatsResponse = { givenLikes: LikeStat[], receivedLikes: LikeStat[] } 5export type LikeStat = { handle?: string, did?: string, avatar?: string, count: number } 6export type LikeStatHash = Record<string, LikeStat> 7 8export class LikeStats { 9 scanStartTime: number | undefined; 10 appView: BlueskyAPI; 11 progressPosts: number; 12 progressLikeRecords: number; 13 progressPostLikes: number; 14 onProgress: ((days: number) => void) | undefined 15 abortController?: AbortController; 16 17 constructor() { 18 this.appView = new BlueskyAPI('public.api.bsky.app'); 19 20 this.progressPosts = 0; 21 this.progressLikeRecords = 0; 22 this.progressPostLikes = 0; 23 } 24 25 async findLikes(requestedDays: number, onProgress: (days: number) => void): Promise<LikeStatsResponse> { 26 this.onProgress = onProgress; 27 this.resetProgress(); 28 this.scanStartTime = new Date().getTime(); 29 this.abortController = new AbortController(); 30 31 let fetchGivenLikes = this.fetchGivenLikes(requestedDays); 32 33 let receivedLikes = await this.fetchReceivedLikes(requestedDays); 34 let receivedStats = this.sumUpReceivedLikes(receivedLikes); 35 let topReceived = this.getTopEntries(receivedStats); 36 37 let givenLikes = await fetchGivenLikes; 38 let givenStats = this.sumUpGivenLikes(givenLikes); 39 let topGiven = this.getTopEntries(givenStats); 40 41 let profileInfo = await this.appView.getRequest('app.bsky.actor.getProfiles', 42 { actors: topGiven.map(x => x.did) }, 43 { abortSignal: this.abortController!.signal } 44 ); 45 46 for (let profile of profileInfo.profiles) { 47 let user = topGiven.find(x => x.did == profile.did)!; 48 user.handle = profile.handle; 49 user.avatar = profile.avatar; 50 } 51 52 this.scanStartTime = undefined; 53 54 return { givenLikes: topGiven, receivedLikes: topReceived }; 55 } 56 57 async fetchGivenLikes(requestedDays: number): Promise<json[]> { 58 let startTime = this.scanStartTime! 59 60 return await accountAPI.fetchAll('com.atproto.repo.listRecords', { 61 params: { 62 repo: accountAPI.user.did, 63 collection: 'app.bsky.feed.like', 64 limit: 100 65 }, 66 field: 'records', 67 breakWhen: (x) => Date.parse(x['value']['createdAt']) < startTime - 86400 * requestedDays * 1000, 68 onPageLoad: (data) => { 69 let last = data.at(-1); 70 71 if (!last) { return } 72 73 let lastDate = Date.parse(last.value.createdAt); 74 let daysBack = (startTime - lastDate) / 86400 / 1000; 75 76 this.updateProgress({ likeRecords: Math.min(1.0, daysBack / requestedDays) }); 77 }, 78 abortSignal: this.abortController!.signal 79 }); 80 } 81 82 async fetchReceivedLikes(requestedDays: number): Promise<json[]> { 83 let startTime = this.scanStartTime! 84 85 let myPosts = await this.appView.loadUserTimeline(accountAPI.user.did, requestedDays, { 86 filter: 'posts_with_replies', 87 onPageLoad: (data) => { 88 let last = data.at(-1); 89 90 if (!last) { return } 91 92 let lastDate = feedPostTime(last); 93 let daysBack = (startTime - lastDate) / 86400 / 1000; 94 95 this.updateProgress({ posts: Math.min(1.0, daysBack / requestedDays) }); 96 }, 97 abortSignal: this.abortController!.signal 98 }); 99 100 let likedPosts = myPosts.filter(x => !x['reason'] && x['post']['likeCount'] > 0); 101 102 let results: json[][] = []; 103 104 for (let i = 0; i < likedPosts.length; i += 10) { 105 let batch = likedPosts.slice(i, i + 10); 106 this.updateProgress({ postLikes: i / likedPosts.length }); 107 108 let fetchBatch = batch.map(x => { 109 return this.appView.fetchAll('app.bsky.feed.getLikes', { 110 params: { 111 uri: x['post']['uri'], 112 limit: 100 113 }, 114 field: 'likes', 115 abortSignal: this.abortController!.signal 116 }); 117 }); 118 119 let batchResults = await Promise.all(fetchBatch); 120 results = results.concat(batchResults); 121 } 122 123 this.updateProgress({ postLikes: 1.0 }); 124 125 return results.flat(); 126 } 127 128 sumUpReceivedLikes(likes: json[]): LikeStatHash { 129 let stats: LikeStatHash = {}; 130 131 for (let like of likes) { 132 let handle = like.actor.handle; 133 134 if (!stats[handle]) { 135 stats[handle] = { handle: handle, count: 0, avatar: like.actor.avatar }; 136 } 137 138 stats[handle].count += 1; 139 } 140 141 return stats; 142 } 143 144 sumUpGivenLikes(likes: json[]): LikeStatHash { 145 let stats: LikeStatHash = {}; 146 147 for (let like of likes) { 148 let did = atURI(like.value.subject.uri).repo; 149 150 if (!stats[did]) { 151 stats[did] = { did: did, count: 0 }; 152 } 153 154 stats[did].count += 1; 155 } 156 157 return stats; 158 } 159 160 getTopEntries(counts: LikeStatHash): LikeStat[] { 161 return Object.entries(counts).sort(this.sortResults).map(x => x[1]).slice(0, 25); 162 } 163 164 resetProgress() { 165 this.progressPosts = 0; 166 this.progressLikeRecords = 0; 167 this.progressPostLikes = 0; 168 169 this.onProgress?.(0); 170 } 171 172 updateProgress(data: { posts?: number, likeRecords?: number, postLikes?: number }) { 173 if (data.posts) { 174 this.progressPosts = data.posts; 175 } 176 177 if (data.likeRecords) { 178 this.progressLikeRecords = data.likeRecords; 179 } 180 181 if (data.postLikes) { 182 this.progressPostLikes = data.postLikes; 183 } 184 185 let totalProgress = ( 186 0.1 * this.progressPosts + 187 0.65 * this.progressLikeRecords + 188 0.25 * this.progressPostLikes 189 ); 190 191 this.onProgress?.(totalProgress); 192 } 193 194 sortResults(a: [string, LikeStat], b: [string, LikeStat]): -1 | 1 | 0 { 195 if (a[1].count < b[1].count) { 196 return 1; 197 } else if (a[1].count > b[1].count) { 198 return -1; 199 } else { 200 return 0; 201 } 202 } 203 204 abortScan() { 205 this.scanStartTime = undefined; 206 this.onProgress = undefined; 207 this.abortController?.abort(); 208 delete this.abortController; 209 } 210}