Thread viewer for Bluesky

posting stats page (MVP)

+13
api.js
··· 302 302 return { cursor: response.cursor, posts }; 303 303 } 304 304 305 + async loadTimeline(days) { 306 + let now = new Date(); 307 + let timeLimit = now.getTime() - days * 86400 * 1000; 308 + 309 + return await this.fetchAll('app.bsky.feed.getTimeline', { limit: 100 }, { 310 + field: 'feed', 311 + breakWhen: (x) => { 312 + let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt; 313 + return Date.parse(timestamp) < timeLimit; 314 + } 315 + }); 316 + } 317 + 305 318 /** @param {string} postURI, @returns {Promise<json>} */ 306 319 307 320 async loadPost(postURI) {
+37
index.html
··· 88 88 </form> 89 89 </div> 90 90 91 + <div id="posting_stats_page"> 92 + <h2>Bluesky posting statistics</h2> 93 + 94 + <form> 95 + <p> 96 + Scan posts from: 97 + <input type="radio" name="scan_type" id="scan_type_timeline" value="timeline" checked> 98 + <label for="scan_type_timeline">Your timeline</label> 99 + <input type="radio" name="scan_type" id="scan_type_users" value="users" disabled> 100 + <label for="scan_type_users">Selected users</label> 101 + </p> 102 + 103 + <p> 104 + Time range: <input type="range" min="1" max="90" value="30"> 30 days 105 + </p> 106 + 107 + <p> 108 + <input type="submit" value="Start scan"> 109 + </p> 110 + </form> 111 + 112 + <table class="scan-result"> 113 + <thead> 114 + <tr> 115 + <th>#</th> 116 + <th>Handle</th> 117 + <th>All posts /d</th> 118 + <th>Own posts /d</th> 119 + <th>Reposts /d</th> 120 + <th>%</th> 121 + </tr> 122 + </thead> 123 + <tbody> 124 + </tbody> 125 + </table> 126 + </div> 127 + 91 128 <script src="lib/purify.min.js"></script> 92 129 <script src="minisky.js"></script> 93 130 <script src="api.js"></script>
+52 -1
minisky.js
··· 14 14 15 15 16 16 /** 17 + * Thrown when passed arguments/options are invalid or missing. 18 + */ 19 + 20 + class RequestError extends Error {} 21 + 22 + 23 + /** 17 24 * Thrown when authentication is needed, but access token is invalid or missing. 18 25 */ 19 26 ··· 97 104 let host = (this.host.includes('://')) ? this.host : `https://${this.host}`; 98 105 return host + '/xrpc'; 99 106 } else { 100 - throw new AuthError('Hostname not set'); 107 + throw new RequestError('Hostname not set'); 101 108 } 102 109 } 103 110 ··· 172 179 return await this.parseResponse(response); 173 180 } 174 181 182 + async fetchAll(method, params, options) { 183 + if (!options || !options.field) { 184 + throw new RequestError("'field' option is required"); 185 + } 186 + 187 + let data = []; 188 + let reqParams = params ?? {}; 189 + let reqOptions = this.sliceOptions(options, ['auth', 'headers']); 190 + 191 + for (;;) { 192 + let response = await this.getRequest(method, reqParams, reqOptions); 193 + 194 + let items = response[options.field]; 195 + let cursor = response.cursor; 196 + 197 + if (options.breakWhen && items.some(x => options.breakWhen(x))) { 198 + let filtered = items.filter(x => !options.breakWhen(x)); 199 + data = data.concat(filtered); 200 + break; 201 + } 202 + 203 + data = data.concat(items); 204 + reqParams.cursor = cursor; 205 + 206 + if (items.length == 0 || !cursor) { 207 + break; 208 + } 209 + } 210 + 211 + return data; 212 + } 213 + 175 214 /** @param {string | boolean} auth, @returns {Record<string, string>} */ 176 215 177 216 authHeaders(auth) { ··· 186 225 } else { 187 226 return {}; 188 227 } 228 + } 229 + 230 + sliceOptions(options, list) { 231 + let newOptions = {}; 232 + 233 + for (let i of list) { 234 + if (i in options) { 235 + newOptions[i] = options[i]; 236 + } 237 + } 238 + 239 + return newOptions; 189 240 } 190 241 191 242 /** @param {string} token, @returns {number} */
+59
skythread.js
··· 447 447 if (page == 'notif') { 448 448 showLoader(); 449 449 showNotificationsPage(); 450 + } else if (page == 'posting_stats') { 451 + showPostingStatsPage(); 450 452 } 453 + } 454 + 455 + function showPostingStatsPage() { 456 + $id('posting_stats_page').style.display = 'block'; 457 + 458 + let days = 7; 459 + 460 + accountAPI.loadTimeline(days).then(items => { 461 + let users = {}; 462 + let total = 0; 463 + 464 + for (let item of items) { 465 + if (item.reply) { continue; } 466 + 467 + let user = item.reason ? item.reason.by.handle : item.post.author.handle; 468 + users[user] = users[user] ?? { handle: user, own: 0, reposts: 0 }; 469 + total += 1; 470 + 471 + if (item.reason) { 472 + users[user].reposts += 1; 473 + } else { 474 + users[user].own += 1; 475 + } 476 + } 477 + 478 + let sorted = Object.values(users).sort((a, b) => { 479 + let asum = a.own + a.reposts; 480 + let bsum = b.own + b.reposts; 481 + 482 + if (asum < bsum) { 483 + return 1; 484 + } else if (asum > bsum) { 485 + return -1; 486 + } else { 487 + return 0; 488 + } 489 + }); 490 + 491 + let tbody = $id('posting_stats_page').querySelector('table.scan-result tbody'); 492 + tbody.innerHTML = ''; 493 + 494 + for (let i = 0; i < sorted.length; i++) { 495 + let user = sorted[i]; 496 + let tr = $tag('tr'); 497 + 498 + tr.append( 499 + $tag('td', { text: i + 1 }), 500 + $tag('td.handle', { text: user.handle }), 501 + $tag('td', { text: ((user.own + user.reposts) / days).toFixed(1) }), 502 + $tag('td', { text: (user.own / days).toFixed(1) }), 503 + $tag('td', { text: (user.reposts / days).toFixed(1) }), 504 + $tag('td', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) }) 505 + ); 506 + 507 + tbody.append(tr); 508 + } 509 + }); 451 510 } 452 511 453 512 function showNotificationsPage() {
+26
style.css
··· 717 717 margin-top: 25px; 718 718 } 719 719 720 + #posting_stats_page { 721 + display: none; 722 + } 723 + 724 + #posting_stats_page .scan-result { 725 + border: 1px solid #333; 726 + border-collapse: collapse; 727 + } 728 + 729 + #posting_stats_page .scan-result td, #posting_stats_page .scan-result th { 730 + border: 1px solid #333; 731 + padding: 5px 8px; 732 + } 733 + 734 + #posting_stats_page .scan-result td { 735 + text-align: right; 736 + } 737 + 738 + #posting_stats_page .scan-result th { 739 + text-align: center; 740 + } 741 + 742 + #posting_stats_page .scan-result td.handle { 743 + text-align: left; 744 + } 745 + 720 746 @media (prefers-color-scheme: dark) { 721 747 body { 722 748 background-color: rgb(39, 39, 37);