Thread viewer for Bluesky
at master 213 lines 6.4 kB view raw
1import { BlueskyAPI, accountAPI } from '../api.js'; 2import { feedPostTime } from '../utils.js'; 3 4/** 5 * Manages the Posting Stats page. 6 */ 7 8type GenerateResultsOptions = { 9 countFetchedDays?: boolean 10 users?: UserWithHandle[] 11} 12 13export type OnProgress = ((progress: number) => void); 14 15export type UserWithHandle = { 16 did: string, 17 handle: string, 18 avatar?: string 19} 20 21export type PostingStatsResultRow = { 22 handle: string, 23 avatar: string | undefined, 24 own: number, 25 reposts: number, 26 all: number 27} 28 29export type PostingStatsResult = { 30 users: PostingStatsResultRow[], 31 sums: { own: number, reposts: number, all: number }, 32 fetchedDays: number, 33 daysBack: number 34} 35 36export class PostingStats { 37 appView: BlueskyAPI; 38 userProgress: Record<string, { pages: number, progress: number }>; 39 onProgress: OnProgress | undefined; 40 abortController?: AbortController; 41 42 constructor(onProgress?: OnProgress) { 43 this.onProgress = onProgress; 44 this.appView = new BlueskyAPI('public.api.bsky.app'); 45 this.userProgress = {}; 46 } 47 48 async scanHomeTimeline(requestedDays: number): Promise<PostingStatsResult | null> { 49 let startTime = new Date().getTime(); 50 this.abortController = new AbortController(); 51 52 let posts = await accountAPI.loadHomeTimeline(requestedDays, { 53 onPageLoad: (data) => this.updateProgress(data, startTime), 54 abortSignal: this.abortController.signal, 55 keepLastPage: true 56 }); 57 58 return this.generateResults(posts, requestedDays, startTime); 59 } 60 61 async scanListTimeline(listURI: string, requestedDays: number): Promise<PostingStatsResult | null> { 62 let startTime = new Date().getTime(); 63 this.abortController = new AbortController(); 64 65 let posts = await accountAPI.loadListTimeline(listURI, requestedDays, { 66 onPageLoad: (data) => this.updateProgress(data, startTime), 67 abortSignal: this.abortController.signal, 68 keepLastPage: true 69 }); 70 71 return this.generateResults(posts, requestedDays, startTime); 72 } 73 74 async scanUserTimelines(users: UserWithHandle[], requestedDays: number): Promise<PostingStatsResult | null> { 75 let startTime = new Date().getTime(); 76 let dids = users.map(u => u.did); 77 this.resetUserProgress(dids); 78 this.abortController = new AbortController(); 79 80 let abortSignal = this.abortController.signal; 81 let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, { 82 filter: 'posts_and_author_threads', 83 onPageLoad: (data) => this.updateUserProgress(did, data, startTime, requestedDays), 84 abortSignal: abortSignal, 85 keepLastPage: true 86 })); 87 88 let datasets = await Promise.all(requests); 89 let posts = datasets.flat(); 90 91 return this.generateResults(posts, requestedDays, startTime, { countFetchedDays: false, users: users }); 92 } 93 94 async scanYourTimeline(requestedDays: number): Promise<PostingStatsResult | null> { 95 let startTime = new Date().getTime(); 96 this.abortController = new AbortController(); 97 98 let posts = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, { 99 filter: 'posts_no_replies', 100 onPageLoad: (data) => this.updateProgress(data, startTime), 101 abortSignal: this.abortController.signal, 102 keepLastPage: true 103 }); 104 105 return this.generateResults(posts, requestedDays, startTime); 106 } 107 108 generateResults(posts: json[], requestedDays: number, startTime: number, options: GenerateResultsOptions = {}) { 109 let last = posts.at(-1); 110 111 if (!last) { 112 return null; 113 } 114 115 let users: Record<string, PostingStatsResultRow> = {}; 116 117 let lastDate = feedPostTime(last); 118 let fetchedDays = (startTime - lastDate) / 86400 / 1000; 119 let daysBack: number; 120 121 if (options.countFetchedDays !== false) { 122 daysBack = Math.min(requestedDays, fetchedDays); 123 } else { 124 daysBack = requestedDays; 125 } 126 127 let timeLimit = startTime - requestedDays * 86400 * 1000; 128 posts = posts.filter(x => (feedPostTime(x) > timeLimit)); 129 posts.reverse(); 130 131 if (options.users) { 132 for (let user of options.users) { 133 users[user.handle] = { handle: user.handle, own: 0, reposts: 0, avatar: user.avatar } as PostingStatsResultRow; 134 } 135 } 136 137 let ownThreads = new Set(); 138 let sums = { own: 0, reposts: 0, all: 0 }; 139 140 for (let item of posts) { 141 if (item.reply) { 142 if (!ownThreads.has(item.reply.parent.uri)) { 143 continue; 144 } 145 } 146 147 let user = item.reason ? item.reason.by : item.post.author; 148 let handle = user.handle; 149 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar }; 150 151 if (item.reason) { 152 users[handle].reposts += 1; 153 sums.reposts += 1; 154 } else { 155 users[handle].own += 1; 156 sums.own += 1; 157 ownThreads.add(item.post.uri); 158 } 159 } 160 161 let userRows = Object.values(users); 162 userRows.forEach((u) => { u.all = u.own + u.reposts }); 163 userRows.sort((a, b) => b.all - a.all); 164 165 sums.all = sums.own + sums.reposts; 166 167 return { users: userRows, sums, fetchedDays, daysBack }; 168 } 169 170 updateProgress(dataPage: json[], startTime: number) { 171 let last = dataPage.at(-1); 172 173 if (!last) { return } 174 175 let lastDate = feedPostTime(last); 176 let daysBack = (startTime - lastDate) / 86400 / 1000; 177 178 this.onProgress?.(daysBack); 179 } 180 181 resetUserProgress(dids: string[]) { 182 this.userProgress = {}; 183 184 for (let did of dids) { 185 this.userProgress[did] = { pages: 0, progress: 0 }; 186 } 187 } 188 189 updateUserProgress(did: string, dataPage: json[], startTime: number, requestedDays: number) { 190 let last = dataPage.at(-1); 191 192 if (!last) { return } 193 194 let lastDate = feedPostTime(last); 195 let daysBack = (startTime - lastDate) / 86400 / 1000; 196 197 this.userProgress[did].pages += 1; 198 this.userProgress[did].progress = Math.min(daysBack / requestedDays, 1.0); 199 200 let expectedPages = Object.values(this.userProgress).map(x => x.pages / x.progress); 201 let known = expectedPages.filter(x => !isNaN(x)); 202 let expectedTotalPages = known.reduce((a, b) => a + b) / known.length * expectedPages.length; 203 let fetchedPages = Object.values(this.userProgress).map(x => x.pages).reduce((a, b) => a + b); 204 205 let progress = (fetchedPages / expectedTotalPages) * requestedDays; 206 this.onProgress?.(progress); 207 } 208 209 abortScan() { 210 this.abortController?.abort(); 211 delete this.abortController; 212 } 213}