+13
api.js
+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
+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
+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
+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
+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);