This repository has no description
1/***********************************************************************
2 * New functions to resolve handle and service endpoint
3 ***********************************************************************/
4
5// Resolve a handle (e.g., "dame.bsky.social") into a DID using the atproto resolveHandle endpoint.
6export async function resolveHandleToDid(inputHandle) {
7 const url = `${publicServiceEndpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(inputHandle)}`;
8 const data = await getJSON(url);
9 if (!data.did) {
10 throw new Error("Could not resolve handle to DID.");
11 }
12 return data.did;
13}
14
15// Get the service endpoint for the DID by querying either the PLC directory or (for did:web identities) the well-known DID document.
16export async function getServiceEndpointForDid(resolvedDid) {
17 let url;
18
19 if (resolvedDid.startsWith("did:web:")) {
20 // For did:web, remove the prefix to get the domain.
21 // (Example: "did:web:example.com" → "example.com")
22 const domain = resolvedDid.slice("did:web:".length);
23 // Construct the URL for the well-known DID document.
24 url = `https://${domain}/.well-known/did.json`;
25 } else if (resolvedDid.startsWith("did:plc:")) {
26 // For did:plc, use your PLC directory endpoint.
27 url = `${plcDirectoryEndpoint}/${encodeURIComponent(resolvedDid)}`;
28 } else {
29 throw new Error(`Unsupported DID method for DID: ${resolvedDid}`);
30 }
31
32 // Fetch the DID document
33 const data = await getJSON(url);
34 if (!data.service || !Array.isArray(data.service)) {
35 throw new Error("Could not determine service endpoint for DID.");
36 }
37
38 // Look for the service endpoint.
39 // For PLC DIDs, we specifically look for the service with type "AtprotoPersonalDataServer".
40 // For did:web, we try to pick that service if present, otherwise fallback to the first available entry.
41 let svcEntry;
42 if (resolvedDid.startsWith("did:plc:")) {
43 svcEntry = data.service.find((svc) => svc.type === "AtprotoPersonalDataServer");
44 } else if (resolvedDid.startsWith("did:web:")) {
45 svcEntry = data.service.find((svc) => svc.type === "AtprotoPersonalDataServer") || data.service[0];
46 }
47
48 if (!svcEntry || !svcEntry.serviceEndpoint) {
49 throw new Error("Could not determine service endpoint for DID.");
50 }
51
52 return svcEntry.serviceEndpoint;
53}
54
55
56/***********************************************************************
57 * Global settings and basic caching
58 ***********************************************************************/
59let did = null; // Will be resolved from the handle.
60let handle = null; // Will be set by the caller (from the URL/searchbar).
61let serviceEndpoint = null; // Will be derived from the PLC Directory.
62const plcDirectoryEndpoint = "https://plc.directory";
63const publicServiceEndpoint = "https://public.api.bsky.app";
64
65// Basic in-memory cache to avoid duplicate API calls.
66const cache = {};
67
68/***********************************************************************
69 * Helper Functions
70 ***********************************************************************/
71async function getJSON(url) {
72 try {
73 const response = await fetch(url);
74 if (!response.ok) {
75 throw new Error(`HTTP ${response.status} error for ${url}`);
76 }
77 return await response.json();
78 } catch (err) {
79 console.error("Error in getJSON for", url, err);
80 throw err;
81 }
82}
83
84async function cachedGetJSON(url) {
85 if (cache[url]) return cache[url];
86 const data = await getJSON(url);
87 cache[url] = data;
88 return data;
89}
90
91/***********************************************************************
92 * NEW: Helper Function to Send Account Data to Backend API for Scoring
93 ***********************************************************************/
94async function fetchScores(accountData) {
95 try {
96 const response = await fetch('https://api.cred.blue/api/score', { // Update URL when ready publicly
97 method: 'POST',
98 headers: {
99 'Content-Type': 'application/json'
100 },
101 body: JSON.stringify(accountData)
102 });
103 if (!response.ok) {
104 throw new Error(`Error: ${response.statusText}`);
105 }
106 return await response.json();
107 } catch (error) {
108 console.error("Error fetching scores:", error);
109 throw error;
110 }
111}
112
113/***********************************************************************
114 * Utility Function to Find the First "createdAt" in a Record
115 ***********************************************************************/
116// This function recursively searches for the first occurrence of "createdAt" in an object.
117function findFirstCreatedAt(obj) {
118 if (typeof obj !== 'object' || obj === null) return null;
119 if ('createdAt' in obj) return obj.createdAt;
120 for (const key of Object.keys(obj)) {
121 const value = obj[key];
122 if (typeof value === 'object' && value !== null) {
123 const result = findFirstCreatedAt(value);
124 if (result) return result;
125 }
126 }
127 return null;
128}
129
130/***********************************************************************
131 * Endpoint calls with pagination and caching
132 ***********************************************************************/
133
134// 1. Fetch Profile data (one-shot)
135async function fetchProfile() {
136 const url = `${publicServiceEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`;
137 return await cachedGetJSON(url);
138}
139
140// 2. Fetch all blobs (paginated)
141async function fetchAllBlobsCount(onPage = () => {}, expectedPages = 2, cutoffTime = null) {
142 let urlBase = `${serviceEndpoint}/xrpc/com.atproto.sync.listBlobs?did=${encodeURIComponent(did)}&limit=1000`;
143 let count = 0, cursor = null;
144 do {
145 const url = urlBase + (cursor ? `&cursor=${cursor}` : "");
146 let data;
147 try {
148 data = await cachedGetJSON(url);
149 } catch (err) {
150 console.error("Error fetching blobs:", err);
151 break;
152 }
153 if (Array.isArray(data.cids)) {
154 for (const cid of data.cids) {
155 if (data.blobs && data.blobs[cid]) {
156 const blob = data.blobs[cid];
157 const createdAt = findFirstCreatedAt(blob);
158 if (cutoffTime) {
159 if (createdAt) {
160 const recordTime = new Date(createdAt).getTime();
161 if (recordTime >= cutoffTime) {
162 count += 1;
163 }
164 } else {
165 // No 'createdAt', include it
166 count += 1;
167 }
168 } else {
169 count += 1;
170 }
171 } else {
172 // If blob details aren't available, count it by default
173 count += 1;
174 }
175 }
176 }
177 // Wait a tick before next page
178 await new Promise((resolve) => setTimeout(resolve, 0));
179 cursor = data.cursor || null;
180 } while (cursor);
181 return count;
182}
183
184// 3. Fetch repo description (one-shot)
185async function fetchRepoDescription() {
186 const url = `${serviceEndpoint}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`;
187 return await cachedGetJSON(url);
188}
189
190async function fetchRecordsForCollection(
191 collectionName,
192 onPage = () => {},
193 expectedPages = 50,
194 // Default cutoff: 90 days ago in ms
195 cutoffTime = Date.now() - 90 * 24 * 60 * 60 * 1000
196) {
197 console.log(`\n=== fetchRecordsForCollection: ${collectionName} (cutoff=${new Date(cutoffTime).toISOString()}) ===`);
198 const urlBase = `${serviceEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(
199 did
200 )}&collection=${encodeURIComponent(collectionName)}&limit=100`;
201
202 let records = [];
203 let cursor = null;
204 let pageCount = 0;
205
206 while (true) {
207 pageCount++;
208 const url = cursor ? `${urlBase}&cursor=${cursor}` : urlBase;
209 console.log(`Fetching page #${pageCount} for ${collectionName} with URL:`, url);
210
211 let data;
212 try {
213 data = await cachedGetJSON(url);
214 } catch (err) {
215 console.error("Error fetching records for collection:", collectionName, err);
216 break;
217 }
218
219 if (!data || !Array.isArray(data.records) || data.records.length === 0) {
220 console.log(`No more records returned for ${collectionName}; stopping.`);
221 break; // no more data
222 }
223
224 let minCreatedAt = Infinity;
225 const pageRecords = [];
226
227 // 1) Inspect each record in the current page
228 for (const rec of data.records) {
229 // Attempt to find createdAt
230 let createdAt;
231 if (collectionName === "app.bsky.feed.post" && rec.value?.createdAt) {
232 createdAt = rec.value.createdAt;
233 } else {
234 createdAt = findFirstCreatedAt(rec);
235 }
236
237 // 2) Convert to ms, or fallback to a "current" time for missing createdAt
238 let recordTime;
239 if (!createdAt) {
240 recordTime = Date.now(); // If missing, you could treat it as "include always"
241 console.log(
242 `Record with no createdAt => using "now" to compare. URI: ${rec.uri}`
243 );
244 } else {
245 recordTime = new Date(createdAt).getTime();
246 }
247
248 // 4) Track minimum createdAt in this page
249 minCreatedAt = Math.min(minCreatedAt, recordTime);
250
251 // 5) We'll decide to include this record only if it's >= cutoffTime
252 if (recordTime >= cutoffTime) {
253 pageRecords.push(rec);
254 }
255 }
256
257 // 6) After analyzing all records in the page, if the earliest record is older than cutoff => no more pages
258 if (minCreatedAt < cutoffTime) {
259 console.log(
260 `Found an older record in this page (minCreatedAt=${new Date(
261 minCreatedAt
262 ).toISOString()}); not fetching further pages.`
263 );
264 // We still add the records that were >= cutoffTime from this page
265 records.push(...pageRecords);
266 break;
267 }
268
269 // 7) If all records in this page are >= cutoffTime, add them all
270 records.push(...pageRecords);
271
272 // 8) If there's no cursor => done
273 if (!data.cursor) {
274 console.log(`No cursor in response; done fetching for ${collectionName}.`);
275 break;
276 } else {
277 // Move to next page
278 cursor = data.cursor;
279 }
280
281 // Optional: If we want to avoid infinite loops
282 if (pageCount >= expectedPages) {
283 console.log(`Reached expectedPages (${expectedPages}) for ${collectionName}; stopping.`);
284 break;
285 }
286 }
287
288 console.log(
289 `Finished fetchRecordsForCollection(${collectionName}), got total = ${records.length} records`
290 );
291 return records;
292}
293
294
295
296// 5. Fetch audit log from PLC Directory (one-shot)
297async function fetchAuditLog() {
298 const url = `${plcDirectoryEndpoint}/${encodeURIComponent(did)}/log/audit`;
299 return await cachedGetJSON(url);
300}
301
302async function fetchAuthorFeed(
303 onPage = () => {},
304 expectedPages = 10,
305 cutoffTime = Date.now() - 90 * 24 * 60 * 60 * 1000
306) {
307 console.log(`\n=== fetchAuthorFeed (cutoff=${new Date(cutoffTime).toISOString()}) ===`);
308 const urlBase = `${publicServiceEndpoint}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(
309 did
310 )}&limit=100`;
311
312 let feed = [];
313 let cursor = null;
314 let pageCount = 0;
315
316 while (true) {
317 pageCount++;
318 const url = cursor ? `${urlBase}&cursor=${cursor}` : urlBase;
319 console.log(`Fetching page #${pageCount} with URL:`, url);
320
321 let data;
322 try {
323 data = await cachedGetJSON(url);
324 } catch (err) {
325 console.error("Error fetching author feed:", err);
326 break;
327 }
328
329 if (!data || !Array.isArray(data.feed) || data.feed.length === 0) {
330 console.log("No more feed items returned; stopping.");
331 break;
332 }
333
334 let minCreatedAt = Infinity;
335 const pageItems = [];
336
337 for (const item of data.feed) {
338 // Only look at the main post's createdAt, not any nested reply data
339 let createdAt = item.post?.record?.createdAt;
340
341 let itemTime;
342 if (!createdAt) {
343 itemTime = Date.now();
344 console.log(
345 `Feed item with no createdAt => using now. Post URI: ${item.post?.uri}`
346 );
347 } else {
348 itemTime = new Date(createdAt).getTime();
349 }
350
351 console.log(
352 `Feed item with createdAt=${createdAt}, itemTime=${itemTime}, cutoffTime=${cutoffTime}, URI=${item.post?.uri}`
353 );
354
355 minCreatedAt = Math.min(minCreatedAt, itemTime);
356
357 if (itemTime >= cutoffTime) {
358 pageItems.push(item);
359 }
360 }
361
362 if (minCreatedAt < cutoffTime) {
363 console.log(
364 `Encountered a main post older than cutoff in this page (minCreatedAt=${new Date(
365 minCreatedAt
366 ).toISOString()}). Stopping further pagination.`
367 );
368 feed.push(...pageItems);
369 break;
370 }
371
372 feed.push(...pageItems);
373
374 if (!data.cursor) {
375 console.log(`No cursor in response; done fetching feed.`);
376 break;
377 } else {
378 cursor = data.cursor;
379 }
380
381 if (pageCount >= expectedPages) {
382 console.log(`Hit expectedPages=${expectedPages}; stopping feed fetch.`);
383 break;
384 }
385 }
386
387 console.log(`Finished fetchAuthorFeed. Got total = ${feed.length} items.`);
388 return feed;
389}
390
391
392/***********************************************************************
393 * Calculation Functions
394 ***********************************************************************/
395function roundToTwo(num) {
396 return Number(num.toFixed(2));
397}
398
399function roundNumbers(obj) {
400 if (Array.isArray(obj)) {
401 return obj.map(roundNumbers);
402 } else if (typeof obj === "object" && obj !== null) {
403 const newObj = {};
404 for (let key in obj) {
405 newObj[key] = roundNumbers(obj[key]);
406 }
407 return newObj;
408 } else if (typeof obj === "number") {
409 return roundToTwo(obj);
410 } else {
411 return obj;
412 }
413}
414
415function calculateAge(createdAt) {
416 const created = new Date(createdAt);
417 const today = new Date();
418 const diffTime = Math.abs(today - created);
419 const ageInDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
420 const refDate = new Date("2022-11-17T00:35:16.391Z");
421 const daysSinceRef = Math.floor(Math.abs(today - refDate) / (1000 * 60 * 60 * 24));
422 const agePercentage = daysSinceRef > 0 ? ageInDays / daysSinceRef : 0;
423 return { ageInDays, agePercentage };
424}
425
426function calculatePostingStyle(stats) {
427 const {
428 onlyPostsPerDay = 0,
429 replyOtherPercentage = 0,
430 textPercentage = 0,
431 imagePercentage = 0,
432 videoPercentage = 0,
433 linkPercentage = 0,
434 altTextPercentage = 0,
435 postsPerDay = 0,
436 } = stats;
437
438 // Check for lurker first
439 if (postsPerDay < 0.1 && stats.totalBskyRecordsPerDay > 0.3) {
440 return "Lurker";
441 }
442
443 // If posting regularly (removed engagement check)
444 if (onlyPostsPerDay > 0.8) {
445 if (textPercentage > linkPercentage && textPercentage > imagePercentage && textPercentage > videoPercentage) {
446 return "Text Poster";
447 }
448 if (imagePercentage > linkPercentage && imagePercentage > textPercentage && imagePercentage > videoPercentage) {
449 return altTextPercentage <= 0.3 ? "Image Poster whose working on using alt text" : "Image Poster";
450 }
451 if (linkPercentage > imagePercentage && linkPercentage > textPercentage && linkPercentage > videoPercentage) {
452 return "Link Poster";
453 }
454 if (videoPercentage > imagePercentage && videoPercentage > textPercentage && videoPercentage > linkPercentage) {
455 return "Video Poster";
456 }
457 return "Mixed Content Poster";
458 }
459
460 // Special interaction types
461 if (replyOtherPercentage >= 0.5) return "Reply Guy";
462 if (stats.quoteOtherPercentage >= 0.5) return "Quote Guy";
463 if (stats.repostOtherPercentage >= 0.5) return "Repost Guy";
464
465 return "Unknown";
466}
467
468// 1. First, add this new function to calculate engagement rate
469function calculateEngagementMetrics(engagementsReceived = {}, onlyPosts = 0, followersCount = 0) {
470 // Ensure we have valid numbers, defaulting to 0 if undefined
471 const totalEngagements = (
472 (engagementsReceived?.likesReceived || 0) +
473 (engagementsReceived?.repostsReceived || 0) +
474 (engagementsReceived?.quotesReceived || 0) +
475 (engagementsReceived?.repliesReceived || 0)
476 );
477
478 const safePostsCount = Number(onlyPosts) || 0;
479 const safeFollowersCount = Number(followersCount) || 0;
480
481 return {
482 totalEngagements: roundToTwo(totalEngagements),
483 engagementsPerPost: safePostsCount > 0 ? roundToTwo(totalEngagements / safePostsCount) : 0,
484 engagementRate: (safePostsCount > 0 && safeFollowersCount > 0)
485 ? roundToTwo((totalEngagements / (safePostsCount * safeFollowersCount)) * 100)
486 : 0
487 };
488}
489
490function calculateSocialStatus({ ageInDays = 0, followersCount = 0, followsCount = 0, engagementRate = 0 }) {
491 // Define the minimum engagement rate threshold for advancing to higher tiers
492 const MIN_ENGAGEMENT_RATE = 0.01; // 1%
493
494 // Check for Newcomer first (less than 30 days old)
495 if (ageInDays < 30) {
496 return "Newcomer";
497 }
498
499 // Default status for accounts older than 30 days
500 let status = "Explorer";
501
502 // Check follower counts and engagement rate for higher tiers
503 if (followersCount >= 100000) {
504 if (engagementRate >= MIN_ENGAGEMENT_RATE) {
505 status = "Leader";
506 } else {
507 // Fallback to Guide if engagement requirement not met
508 status = "Guide";
509 }
510 } else if (followersCount >= 10000) {
511 if (engagementRate >= MIN_ENGAGEMENT_RATE) {
512 status = "Guide";
513 } else {
514 // Fallback to Pathfinder if engagement requirement not met
515 status = "Pathfinder";
516 }
517 } else if (followersCount >= 1000) {
518 if (engagementRate >= MIN_ENGAGEMENT_RATE) {
519 status = "Pathfinder";
520 }
521 // Fallback to Explorer if engagement requirement not met
522 }
523
524 // Add engagement qualifier based on rate
525 if (engagementRate > 0.03) { // 3%
526 return `Highly Engaging ${status}`;
527 } else if (engagementRate > 0.01) { // 1%
528 return `Engaging ${status}`;
529 }
530
531 // Return base status
532 return status;
533}
534
535function calculateActivityStatus(rate) {
536 if (rate === 0) return "inactive";
537 if (rate > 0 && rate < 10) return "barely active";
538 if (rate >= 10 && rate < 25) return "active";
539 if (rate >= 25 && rate < 100) return "very active";
540 if (rate >= 100) return "extremely active";
541}
542
543function calculateProfileCompletion(profile) {
544 const hasDisplayName = Boolean(profile.displayName && profile.displayName.trim());
545 const hasBanner = Boolean(profile.banner && profile.banner.trim());
546 const hasDescription = Boolean(profile.description && profile.description.trim());
547 if (hasDisplayName && hasBanner && hasDescription) return "complete";
548 if (hasDisplayName || hasBanner || hasDescription) return "incomplete";
549 return "not started";
550}
551
552function calculateDomainRarity(handle) {
553 if (handle.includes("bsky.social")) {
554 const len = handle.length;
555 if (len >= 21) return "very common";
556 if (len >= 18 && len <= 20) return "common";
557 if (len === 17) return "uncommon";
558 if (len === 16) return "rare";
559 if (len === 15) return "very rare";
560 if (len <= 14) return "extremely rare";
561 } else {
562 const standardTLDs = [".com", ".org", ".net"];
563 const hasStandardTLD = standardTLDs.some((tld) => handle.endsWith(tld));
564 let len;
565 if (hasStandardTLD) {
566 const parts = handle.split(".");
567 const domainName = parts[0]; // Take just the domain name part before the TLD
568 len = domainName.length;
569 if (len >= 15) return "very common";
570 if (len >= 12 && len <= 14) return "common";
571 if (len >= 9 && len <= 11) return "uncommon";
572 if (len >= 7 && len <= 8) return "rare";
573 if (len === 6) return "very rare";
574 if (len <= 5) return "extremely rare";
575 } else {
576 len = handle.length;
577 if (len >= 14) return "very common";
578 if (len >= 11 && len <= 13) return "common";
579 if (len >= 8 && len <= 10) return "uncommon";
580 if (len >= 6 && len <= 7) return "rare";
581 if (len === 5) return "very rare";
582 if (len <= 4) return "extremely rare";
583 }
584 }
585 return "unknown";
586}
587
588function calculateEra(createdAt) {
589 const created = new Date(createdAt);
590 if (created >= new Date("2022-11-16") && created <= new Date("2023-01-31")) {
591 return "Pre-history";
592 } else if (created >= new Date("2023-02-01") && created <= new Date("2024-01-31")) {
593 return "Invite-only";
594 } else if (created > new Date("2024-01-31")) {
595 return "Public release";
596 }
597 return "Unknown";
598}
599
600/***********************************************************************
601 * Main Function – Build accountData90Days and accountData30Days JSON objects.
602 ***********************************************************************/
603export async function loadAccountData(inputHandle, onProgress = () => {}) {
604 try {
605 // Validate input handle
606 if (!inputHandle) throw new Error("Handle is not provided");
607 handle = inputHandle;
608
609 // Resolve handle to DID and get service endpoint
610 did = await resolveHandleToDid(handle);
611 serviceEndpoint = await getServiceEndpointForDid(did);
612
613 // Fetch profile
614 const profile = await fetchProfile();
615
616 // Calculate age
617 const { ageInDays, agePercentage } = calculateAge(profile.createdAt);
618
619 // Fetch blobs (all-time)
620 const cutoffTimeAll = null; // No cutoff for all-time data
621 const blobsCountAll = await fetchAllBlobsCount(() => {}, 10, cutoffTimeAll);
622
623 // Fetch repo description
624 const repoDescription = await fetchRepoDescription();
625 let collections = repoDescription.collections || [];
626 const totalCollections = collections.length;
627 const bskyCollectionNames = collections.filter((col) => col.startsWith("app.bsky"));
628 const totalBskyCollections = bskyCollectionNames.length;
629 const totalNonBskyCollections = totalCollections - totalBskyCollections;
630
631 // Build targetCollections array
632 const targetCollections = [...new Set(collections)];
633
634 // Parse audit log (or use defaults for did:web identities)
635 let plcOperations;
636 let totalAkas, totalBskyAkas, totalCustomAkas, rotationKeysRounded, activeAkasRounded;
637
638 if (did.startsWith("did:web:")) {
639 // For did:web identities, skip fetching the audit log and use default values
640 totalAkas = 1;
641 totalBskyAkas = 0;
642 totalCustomAkas = 1;
643 rotationKeysRounded = 3;
644 activeAkasRounded = 1;
645 plcOperations = 0;
646 } else {
647 // For did:plc identities, fetch and process the audit log as usual
648 const rawAuditData = await fetchAuditLog();
649 let auditRecords = Array.isArray(rawAuditData)
650 ? rawAuditData
651 : Object.values(rawAuditData);
652 plcOperations = auditRecords.length;
653 let rotationKeys = 0;
654 let activeAkas = 0;
655 let akaSet = new Set();
656
657 if (plcOperations > 0) {
658 auditRecords.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
659 const latestRecord = auditRecords[auditRecords.length - 1];
660 if (latestRecord.operation && Array.isArray(latestRecord.operation.rotationKeys)) {
661 rotationKeys = latestRecord.operation.rotationKeys.length;
662 }
663 if (latestRecord.operation && Array.isArray(latestRecord.operation.alsoKnownAs)) {
664 activeAkas = latestRecord.operation.alsoKnownAs.length;
665 }
666 auditRecords.forEach((record) => {
667 if (record.operation && Array.isArray(record.operation.alsoKnownAs)) {
668 record.operation.alsoKnownAs.forEach((alias) => {
669 akaSet.add(alias);
670 });
671 }
672 });
673 }
674 totalAkas = akaSet.size;
675 totalBskyAkas = Array.from(akaSet).filter((alias) =>
676 alias.includes("bsky.social")
677 ).length;
678 totalCustomAkas = roundToTwo(totalAkas - totalBskyAkas);
679 rotationKeysRounded = roundToTwo(rotationKeys);
680 activeAkasRounded = roundToTwo(activeAkas);
681 }
682
683 // Define periods for 30 and 90 days
684 const periods = [
685 { days: 30, label: "30Days" },
686 { days: 90, label: "90Days" },
687 ];
688
689 // Initialize objects to hold data for each period
690 const accountDataPerPeriod = {};
691
692 for (const period of periods) {
693 const { days, label } = period;
694 const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000;
695
696 // Aggregate record counts for the period
697 const { totalRecords, totalBskyRecords, totalNonBskyRecords, collectionStats, weeklyActivity } =
698 await calculateRecordsAggregate(targetCollections, days, cutoffTime);
699 const totalRecordsPerDay = days ? totalRecords / days : 0;
700 const totalBskyRecordsPerDay = days ? totalBskyRecords / days : 0;
701 const totalNonBskyRecordsPerDay = days ? totalNonBskyRecords / days : 0;
702
703 // Fetch posts and reposts for the period and merge them
704 const postsRecordsPosts = await fetchRecordsForCollection(
705 "app.bsky.feed.post",
706 () => {},
707 20,
708 cutoffTime
709 );
710 const postsRecordsReposts = await fetchRecordsForCollection(
711 "app.bsky.feed.repost",
712 () => {},
713 20,
714 cutoffTime
715 );
716 const postsRecords = postsRecordsPosts.concat(postsRecordsReposts);
717
718 const postsCount = postsRecords.length;
719 const postStats = computePostStats(postsRecords, days);
720
721 // Compute engagements for the period and merge with postStats
722 // First, calculate engagements
723 const engagements = await calculateEngagements(cutoffTime);
724
725 // Create complete post stats
726 const completePostStats = {
727 ...postStats,
728 likesReceived: engagements.likesReceived,
729 repostsReceived: engagements.repostsReceived,
730 quotesReceived: engagements.quotesReceived,
731 repliesReceived: engagements.repliesReceived,
732 engagementsReceived: {
733 likesReceived: engagements.likesReceived,
734 repostsReceived: engagements.repostsReceived,
735 quotesReceived: engagements.quotesReceived,
736 repliesReceived: engagements.repliesReceived,
737 }
738 };
739
740 // Compute activity statuses for the period
741 const activityStatus = calculateActivityStatus(totalRecordsPerDay);
742 const bskyActivityStatus = calculateActivityStatus(totalBskyRecordsPerDay);
743 const atprotoActivityStatus = calculateActivityStatus(totalNonBskyRecordsPerDay);
744
745 // Compute posting style for the period
746 const postingStyle = calculatePostingStyle({
747 ...completePostStats, // Use complete stats here
748 totalBskyRecordsPerDay,
749 });
750
751 const engagementMetrics = calculateEngagementMetrics(
752 completePostStats?.engagementsReceived || {},
753 postsCount || 0,
754 profile?.followersCount || 0
755 );
756
757 const socialStatus = calculateSocialStatus({
758 ageInDays: ageInDays || 0,
759 followersCount: profile?.followersCount || 0,
760 followsCount: profile?.followsCount || 0,
761 engagementRate: engagementMetrics?.engagementRate || 0
762 });
763
764 // Build analysis narrative for the period
765 const narrative = buildAnalysisNarrative({
766 profile,
767 activityAll: {
768 activityStatus,
769 bskyActivityStatus,
770 atprotoActivityStatus,
771 totalCollections: roundToTwo(totalCollections),
772 totalBskyCollections: roundToTwo(totalBskyCollections),
773 totalNonBskyCollections: roundToTwo(totalNonBskyCollections),
774 totalRecords: roundToTwo(totalRecords),
775 totalRecordsPerDay: roundToTwo(totalRecordsPerDay),
776 totalBskyRecords: roundToTwo(totalBskyRecords),
777 totalBskyRecordsPerDay: roundToTwo(totalBskyRecordsPerDay),
778 totalBskyRecordsPercentage: totalRecords ? roundToTwo(totalBskyRecords / totalRecords) : 0,
779 totalNonBskyRecords: roundToTwo(totalNonBskyRecords),
780 totalNonBskyRecordsPerDay: roundToTwo(totalNonBskyRecords / days),
781 plcOperations: roundToTwo(plcOperations),
782 ...collectionStats,
783 "app.bsky.feed.post": completePostStats, // Use complete stats here
784 blobsCount: roundToTwo(blobsCountAll),
785 blobsPerDay: ageInDays ? roundToTwo(blobsCountAll / ageInDays) : 0,
786 blobsPerPost: postsCount ? roundToTwo(blobsCountAll / postsCount) : 0,
787 blobsPerImagePost: completePostStats.postsWithImages ? roundToTwo(blobsCountAll / completePostStats.postsWithImages) : 0,
788 },
789 postingStyle,
790 socialStatus,
791 rotationKeys: rotationKeysRounded,
792 engagementMetrics, // Make sure this is passed
793 alsoKnownAs: {
794 totalAkas: roundToTwo(totalAkas),
795 totalCustomAkas: roundToTwo(totalCustomAkas),
796 totalBskyAkas: roundToTwo(totalBskyAkas),
797 },
798 });
799
800 // Build the account data object for this period.
801 let periodData = {
802 // Keep all the basic profile info
803 profile: {
804 ...profile,
805 did: profile.did || did,
806 },
807 displayName: profile.displayName,
808 handle: profile.handle,
809 did: profile.did || did,
810 profileEditedDate: profile.indexedAt,
811 profileCompletion: calculateProfileCompletion(profile),
812
813 // Maintain the activityAll structure for backward compatibility
814 activityAll: {
815 activityStatus,
816 bskyActivityStatus,
817 atprotoActivityStatus,
818 totalCollections: roundToTwo(totalCollections),
819 totalBskyCollections: roundToTwo(totalBskyCollections),
820 totalNonBskyCollections: roundToTwo(totalNonBskyCollections),
821 totalRecords: roundToTwo(totalRecords),
822 totalRecordsPerDay: roundToTwo(totalRecordsPerDay),
823 totalBskyRecords: roundToTwo(totalBskyRecords),
824 totalBskyRecordsPerDay: roundToTwo(totalBskyRecordsPerDay),
825 totalBskyRecordsPercentage: totalRecords ? roundToTwo(totalBskyRecords / totalRecords) : 0,
826 totalNonBskyRecords: roundToTwo(totalNonBskyRecords),
827 totalNonBskyRecordsPerDay: roundToTwo(totalNonBskyRecords / days),
828 totalNonBskyRecordsPercentage: totalRecords ? roundToTwo(totalNonBskyRecords / totalRecords) : 0,
829 plcOperations: roundToTwo(plcOperations),
830 ...collectionStats,
831 "app.bsky.feed.post": completePostStats,
832 blobsCount: roundToTwo(blobsCountAll),
833 blobsPerDay: ageInDays ? roundToTwo(blobsCountAll / ageInDays) : 0,
834 blobsPerPost: postsCount ? roundToTwo(blobsCountAll / postsCount) : 0,
835 blobsPerImagePost: completePostStats.postsWithImages ? roundToTwo(blobsCountAll / completePostStats.postsWithImages) : 0,
836 },
837
838 // Add new categorical structure
839 blueskyCategories: {
840 profileQuality: {
841 profileCompleteness: {
842 avatar: profile.avatar ? true : false,
843 banner: profile.banner ? true : false,
844 description: profile.description ? true : false,
845 pinnedPost: profile.pinnedPost ? true : false
846 },
847 altTextConsistency: completePostStats.altTextPercentage || 0,
848 customDomain: !profile.handle.includes("bsky.social"),
849 score: 0,
850 },
851 communityEngagement: {
852 socialGraph: {
853 followersCount: profile.followersCount,
854 followsCount: profile.followsCount,
855 followRatio: profile.followersCount ? (profile.followsCount / profile.followersCount) : 0
856 },
857 engagement: {
858 ...engagementMetrics,
859 replyRate: completePostStats.replyOtherPercentage || 0
860 },
861 score: 0,
862 },
863 contentActivity: {
864 posts: {
865 totalBskyRecords: totalBskyRecords,
866 postsPerDay: totalBskyRecordsPerDay,
867 collections: totalBskyCollections
868 },
869 contentQuality: {
870 labels: profile.labels || [],
871 postStats: completePostStats
872 },
873 score: 0,
874 },
875 recognitionStatus: {
876 accountAge: {
877 ageInDays: ageInDays,
878 agePercentage: agePercentage,
879 era: calculateEra(profile.createdAt)
880 },
881 status: {
882 socialStatus: socialStatus,
883 postingStyle: postingStyle
884 },
885 score: 0,
886 }
887 },
888
889 atprotoCategories: {
890 decentralization: {
891 pds: {
892 serviceEndpoint,
893 isThirdParty: !serviceEndpoint.includes("bsky.network"),
894 pdsType: serviceEndpoint.includes("bsky.network") ? "Bluesky" : "Third-party"
895 },
896 identity: {
897 did: profile.did || did,
898 isDidWeb: (profile.did || did).startsWith("did:web"),
899 rotationKeys: rotationKeysRounded,
900 customDomain: !profile.handle.includes("bsky.social")
901 },
902 score: 0,
903 },
904 protocolActivity: {
905 collections: {
906 totalNonBskyCollections,
907 totalNonBskyRecords,
908 recordsPerDay: totalNonBskyRecordsPerDay
909 },
910 score: 0,
911 },
912 accountMaturity: {
913 age: {
914 ageInDays,
915 agePercentage,
916 createdAt: profile.createdAt
917 },
918 plcOperations: plcOperations,
919 score: 0,
920 }
921 },
922
923 // Keep other necessary metadata fields
924 serviceEndpoint,
925 pdsType: serviceEndpoint.includes("bsky.network") ? "Bluesky" : "Third-party",
926 createdAt: profile.createdAt,
927 ageInDays: roundToTwo(ageInDays),
928 agePercentage: roundToTwo(agePercentage),
929 followersCount: roundToTwo(profile.followersCount),
930 followsCount: roundToTwo(profile.followsCount),
931 postsCount: roundToTwo(postsCount),
932 rotationKeys: rotationKeysRounded,
933 era: calculateEra(profile.createdAt),
934 postingStyle,
935 socialStatus,
936 engagementMetrics,
937 weeklyActivity,
938 analysis: {
939 narrative: {
940 narrative1: narrative.narrative1,
941 narrative2: narrative.narrative2,
942 narrative3: narrative.narrative3,
943 }
944 }
945 };
946
947 // Send the account data object to the backend scoring API
948 periodData = await fetchScores(periodData);
949 accountDataPerPeriod[`accountData${label}`] = periodData;
950 }
951
952 // Build final output JSON.
953 const finalOutput = {
954 message: "accountData retrieved successfully",
955 accountData90Days: accountDataPerPeriod.accountData90Days,
956 accountData30Days: accountDataPerPeriod.accountData30Days,
957 };
958
959 return roundNumbers(finalOutput);
960 } catch (err) {
961 console.error("Error loading account data:", err);
962 return {
963 message: "Error retrieving accountData",
964 error: err.toString(),
965 };
966 }
967}
968
969/***********************************************************************
970 * Additional Helper Functions (if any)
971 ***********************************************************************/
972
973// Build the analysis narrative paragraphs.
974function buildAnalysisNarrative(accountData) {
975 const { profile, activityAll, alsoKnownAs, engagementMetrics = {} } = accountData;
976 const { agePercentage } = calculateAge(profile.createdAt);
977 let accountAgeStatement = "";
978 if (agePercentage >= 0.97) {
979 accountAgeStatement = "since the very beginning and is";
980 } else if (agePercentage >= 0.7) {
981 accountAgeStatement = "for a very long time and is";
982 } else if (agePercentage >= 0.5) {
983 accountAgeStatement = "for a long time and is";
984 } else if (agePercentage >= 0.1) {
985 accountAgeStatement = "for awhile and is";
986 } else if (agePercentage >= 0.02) {
987 accountAgeStatement = "for only a short period of time and is";
988 } else {
989 accountAgeStatement = "for barely any time at all";
990 }
991
992 const totalBskyCollections = activityAll.totalBskyCollections || 0;
993 let blueskyFeatures = "";
994 if (totalBskyCollections >= 12) {
995 blueskyFeatures = "they are using all of Bluesky's core features";
996 } else if (totalBskyCollections >= 8) {
997 blueskyFeatures = "they are using most of Bluesky’s core features";
998 } else if (totalBskyCollections >= 3) {
999 blueskyFeatures = "they are using some of Bluesky’s core features";
1000 } else {
1001 blueskyFeatures = "they haven't used any of Bluesky's core features yet";
1002 }
1003
1004 const totalNonBskyCollections = activityAll.totalNonBskyCollections || 0;
1005 const totalNonBskyRecords = activityAll.totalNonBskyRecords || 0;
1006 let atprotoEngagement = "";
1007 if (totalNonBskyCollections >= 10 && totalNonBskyRecords > 100) {
1008 atprotoEngagement = "is extremely engaged, having used many different services or tools";
1009 } else if (totalNonBskyCollections >= 5 && totalNonBskyRecords > 50) {
1010 atprotoEngagement = "is very engaged, having used many different services or tools";
1011 } else if (totalNonBskyCollections > 0 && totalNonBskyRecords > 5) {
1012 atprotoEngagement = "has dipped their toes in the water, but has yet to go deeper";
1013 } else {
1014 atprotoEngagement = "has not yet explored what's out there";
1015 }
1016
1017 let domainHistoryStatement = "";
1018 if (alsoKnownAs.totalCustomAkas > 0 && profile.handle.includes("bsky.social")) {
1019 domainHistoryStatement = "They've used a custom domain name at some point but are currently using a default Bluesky handle";
1020 } else if (!profile.handle.includes("bsky.social")) {
1021 domainHistoryStatement = "They currently are using a custom domain";
1022 } else if (alsoKnownAs.totalAkas > 2 && !profile.handle.includes("bsky.social")) {
1023 domainHistoryStatement = "They have a custom domain set and have a history of using different aliases";
1024 } else {
1025 domainHistoryStatement = "They still have a default Bluesky handle";
1026 }
1027
1028 // Debug logging to see what values we're working with
1029 console.log('Root rotationKeys:', accountData.rotationKeys);
1030 console.log('Nested rotationKeys:', accountData.atprotoCategories?.decentralization?.identity?.rotationKeys);
1031
1032 const rotationKeyCount = accountData.rotationKeys || 0;
1033 console.log('Final rotationKeyCount used:', rotationKeyCount);
1034
1035 let rotationKeyStatement = (rotationKeyCount === 1 || rotationKeyCount >= 3)
1036 ? "They have their own rotation key set"
1037 : "They don't have their own rotation key set";
1038
1039 console.log('Generated statement:', rotationKeyStatement);
1040
1041 let pdsHostStatement = serviceEndpoint.includes("bsky.network")
1042 ? "their PDS is hosted by a Bluesky mushroom"
1043 : "their PDS is hosted by either a third-party or themselves";
1044
1045 // First Paragraph
1046 const narrative1 =
1047 `${profile.displayName} has been on the network ${accountAgeStatement} ${calculateActivityStatus(activityAll.totalRecordsPerDay)}. ` +
1048 `Their profile is ${calculateProfileCompletion(profile)}, and ${blueskyFeatures}. ` +
1049 `When it comes to the broader ATProto ecosystem, this identity ${atprotoEngagement}.`;
1050
1051 // Second Paragraph
1052 const narrative2 =
1053 `${domainHistoryStatement} which has a rarity level of ${calculateDomainRarity(profile.handle)}. ` +
1054 `${rotationKeyStatement}, and ${pdsHostStatement}.`;
1055
1056 const era = calculateEra(profile.createdAt);
1057 const postingStyle = accountData.postingStyle || "Unknown";
1058 const socialStatus = accountData.socialStatus || "Community Member";
1059
1060 // Safely access engagement metrics with defaults
1061 const {
1062 engagementRate = 0,
1063 engagementsPerPost = 0
1064 } = engagementMetrics;
1065
1066 let engagementPhrase = "";
1067 if (engagementRate > 0.03) {
1068 engagementPhrase = "with exceptionally high engagement";
1069 } else if (engagementRate > 0.01) {
1070 engagementPhrase = "with very high engagement";
1071 } else if (engagementRate > 0.005) {
1072 engagementPhrase = "with high engagement";
1073 } else {
1074 engagementPhrase = "with normal engagement";
1075 }
1076
1077 const narrative3 =
1078 `${profile.displayName || "This user"} first joined Bluesky during the ${era} era. ` +
1079 `Their style of posting is "${postingStyle}". ` +
1080 `They are a "${socialStatus}" ${engagementPhrase}, ` +
1081 `averaging ${engagementsPerPost.toFixed(1)} engagements per post ` +
1082 `across their follower base of ${profile.followersCount || 0}.`;
1083
1084 return { narrative1, narrative2, narrative3 };
1085}
1086
1087/***********************************************************************
1088 * Function to calculate aggregate records for the account by iterating over each collection.
1089 ***********************************************************************/
1090async function calculateRecordsAggregate(collectionNames, periodDays, cutoffTime) {
1091 let totalRecords = 0;
1092 let totalBskyRecords = 0;
1093 let totalNonBskyRecords = 0;
1094 const collectionStats = {};
1095
1096 // Initialize weekly data structure
1097 const weeklyData = Array.from({ length: Math.ceil(periodDays / 7) }, (_, index) => ({
1098 weekNumber: index,
1099 bskyRecords: 0,
1100 nonBskyRecords: 0
1101 }));
1102
1103 const getWeekNumber = (date) => {
1104 const recordDate = new Date(date);
1105 const cutoff = new Date(cutoffTime);
1106 const diffTime = Math.abs(recordDate - cutoff);
1107 const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
1108 return Math.floor(diffDays / 7);
1109 };
1110
1111 for (const col of collectionNames) {
1112 const recs = await fetchRecordsForCollection(col, () => {}, 50, cutoffTime);
1113 const count = recs.length;
1114 const perDay = periodDays ? count / periodDays : 0;
1115
1116 // Process each record for weekly stats
1117 for (const record of recs) {
1118 const createdAt = record.value?.createdAt || findFirstCreatedAt(record);
1119 if (createdAt) {
1120 const weekNum = getWeekNumber(createdAt);
1121 if (weekNum >= 0 && weekNum < weeklyData.length) {
1122 if (col.startsWith("app.bsky")) {
1123 weeklyData[weekNum].bskyRecords++;
1124 } else {
1125 weeklyData[weekNum].nonBskyRecords++;
1126 }
1127 }
1128 }
1129 }
1130
1131 collectionStats[col] = {
1132 count: roundToTwo(count),
1133 perDay: roundToTwo(perDay),
1134 };
1135
1136 totalRecords += count;
1137 if (col.startsWith("app.bsky")) {
1138 totalBskyRecords += count;
1139 } else {
1140 totalNonBskyRecords += count;
1141 }
1142 }
1143
1144 return {
1145 totalRecords,
1146 totalBskyRecords,
1147 totalNonBskyRecords,
1148 collectionStats,
1149 weeklyActivity: weeklyData
1150 };
1151}
1152
1153/***********************************************************************
1154 * Function to calculate engagements for the account using the author feed.
1155 ***********************************************************************/
1156async function calculateEngagements(cutoffTime = null) {
1157 console.log("Starting engagement calculation");
1158 const feed = await fetchAuthorFeed(() => {}, 15, cutoffTime);
1159
1160 let likesReceived = 0;
1161 let repostsReceived = 0;
1162 let quotesReceived = 0;
1163 let repliesReceived = 0;
1164
1165 for (const item of feed) {
1166 // Only consider posts from this author
1167 if (item && item.post && item.post.author && item.post.author.did === did) {
1168 // Only add the direct post metrics, ignoring any nested metrics
1169 if (item.post.likeCount !== undefined) likesReceived += item.post.likeCount;
1170 if (item.post.repostCount !== undefined) repostsReceived += item.post.repostCount;
1171 if (item.post.quoteCount !== undefined) quotesReceived += item.post.quoteCount;
1172 if (item.post.replyCount !== undefined) repliesReceived += item.post.replyCount;
1173 }
1174 }
1175
1176 const results = {
1177 likesReceived: roundToTwo(likesReceived),
1178 repostsReceived: roundToTwo(repostsReceived),
1179 quotesReceived: roundToTwo(quotesReceived),
1180 repliesReceived: roundToTwo(repliesReceived),
1181 };
1182
1183 return results;
1184}
1185
1186/***********************************************************************
1187 * Function to compute post statistics based on records and period
1188 ***********************************************************************/
1189function computePostStats(postsRecords, periodDays) {
1190 function filterRecords(records, testFunc) {
1191 return records.filter(testFunc).length;
1192 }
1193
1194 const postsCount = postsRecords.length;
1195 const onlyPosts = filterRecords(postsRecords, (rec) => !rec.value.hasOwnProperty("reply"));
1196 const onlyReplies = filterRecords(postsRecords, (rec) => rec.value.hasOwnProperty("reply"));
1197 const onlyRepliesToSelf = postsRecords.filter((rec) => {
1198 if (!rec.value || !rec.value.reply || !rec.value.reply.parent) return false;
1199 return rec.value.reply.parent.uri.includes(did);
1200 }).length;
1201 const onlyRepliesToOthers = onlyReplies - onlyRepliesToSelf;
1202 const onlyQuotes = filterRecords(
1203 postsRecords,
1204 (rec) =>
1205 rec.value.embed && rec.value.embed["$type"] === "app.bsky.embed.record"
1206 );
1207 const onlySelfQuotes = filterRecords(postsRecords, (rec) => {
1208 if (
1209 !rec.value ||
1210 !rec.value.embed ||
1211 (rec.value.embed["$type"] !== "app.bsky.embed.record" &&
1212 rec.value.embed["$type"] !== "app.bsky.embed.recordWithMedia")
1213 ) {
1214 return false;
1215 }
1216 const embedRecord = rec.value.embed.record;
1217 return (
1218 (embedRecord.record && embedRecord.record.uri && embedRecord.record.uri.includes(did)) ||
1219 (embedRecord.uri && embedRecord.uri.includes(did))
1220 );
1221 });
1222 const onlyOtherQuotes = onlyQuotes - onlySelfQuotes;
1223
1224 // Reposts
1225 const repostRecords = postsRecords.filter((rec) => rec.value["$type"] === "app.bsky.feed.repost");
1226 const onlyReposts = repostRecords.length;
1227 const onlySelfReposts = filterRecords(repostRecords, (rec) => {
1228 if (!rec.value || !rec.value.subject || !rec.value.subject.uri) return false;
1229 return rec.value.subject.uri.includes(did);
1230 });
1231 const onlyOtherReposts = onlyReposts - onlySelfReposts;
1232
1233 // Images and related stats
1234 const postsWithImages = filterRecords(
1235 postsRecords,
1236 (rec) =>
1237 rec.value.embed && rec.value.embed["$type"] === "app.bsky.embed.images"
1238 );
1239 const imagePostsAltText = filterRecords(postsRecords, (rec) => {
1240 if (!rec.value.embed || rec.value.embed["$type"] !== "app.bsky.embed.images") {
1241 return false;
1242 }
1243 return (
1244 rec.value.embed.images &&
1245 rec.value.embed.images.some((image) => image.alt && image.alt.trim())
1246 );
1247 });
1248 // Compute the count of image posts (with alt text) that are replies.
1249 const imagePostsReplies = filterRecords(postsRecords, (rec) => {
1250 const isImagePostWithAlt = rec.value.embed &&
1251 rec.value.embed["$type"] === "app.bsky.embed.images" &&
1252 rec.value.embed.images &&
1253 rec.value.embed.images.some((img) => img.alt && img.alt.trim());
1254 return isImagePostWithAlt && rec.value.reply;
1255 });
1256 const imagePostsNoAltText = postsWithImages - imagePostsAltText;
1257 const altTextPercentage = postsWithImages ? imagePostsAltText / postsWithImages : 0;
1258 const postsWithOnlyText = filterRecords(
1259 postsRecords,
1260 (rec) =>
1261 !rec.value.embed &&
1262 !rec.value.reply &&
1263 !(rec.value.facets && JSON.stringify(rec.value.facets).includes("app.bsky.richtext.facet#link"))
1264 );
1265 const postsWithMentions = filterRecords(postsRecords, (rec) => {
1266 if (!rec.value || !rec.value.facets) return false;
1267 return rec.value.facets.some((facet) =>
1268 facet.features && facet.features.some((feature) => feature["$type"] === "app.bsky.richtext.facet#mention")
1269 );
1270 });
1271 const postsWithVideo = filterRecords(
1272 postsRecords,
1273 (rec) =>
1274 rec.value.embed && rec.value.embed["$type"] === "app.bsky.embed.video"
1275 );
1276 const postsWithLinks = filterRecords(postsRecords, (rec) => {
1277 if (
1278 rec.value.facets &&
1279 rec.value.facets.features &&
1280 rec.value.facets.features.some((f) => f["$type"] === "app.bsky.richtext.facet#link")
1281 )
1282 return true;
1283 if (rec.value.embed && rec.value.embed["$type"] === "app.bsky.embed.external")
1284 return true;
1285 return false;
1286 });
1287
1288 const postStats = {
1289 postsCount: roundToTwo(postsCount),
1290 postsPerDay: periodDays ? roundToTwo(postsCount / periodDays) : 0,
1291 onlyPosts: roundToTwo(onlyPosts),
1292 onlyPostsPerDay: periodDays ? roundToTwo(onlyPosts / periodDays) : 0,
1293 onlyReplies: roundToTwo(onlyReplies),
1294 onlyRepliesPerDay: periodDays ? roundToTwo(onlyReplies / periodDays) : 0,
1295 onlyRepliesToSelf: roundToTwo(onlyRepliesToSelf),
1296 onlyRepliesToSelfPerDay: periodDays ? roundToTwo(onlyRepliesToSelf / periodDays) : 0,
1297 onlyRepliesToOthers: roundToTwo(onlyRepliesToOthers),
1298 onlyRepliesToOthersPerDay: periodDays ? roundToTwo(onlyRepliesToOthers / periodDays) : 0,
1299 onlyQuotes: roundToTwo(onlyQuotes),
1300 onlyQuotesPerDay: periodDays ? roundToTwo(onlyQuotes / periodDays) : 0,
1301 onlySelfQuotes: roundToTwo(onlySelfQuotes),
1302 onlySelfQuotesPerDay: periodDays ? roundToTwo(onlySelfQuotes / periodDays) : 0,
1303 onlyOtherQuotes: roundToTwo(onlyOtherQuotes),
1304 onlyOtherQuotesPerDay: periodDays ? roundToTwo(onlyOtherQuotes / periodDays) : 0,
1305 onlyReposts: roundToTwo(onlyReposts),
1306 onlyRepostsPerDay: periodDays ? roundToTwo(onlyReposts / periodDays) : 0,
1307 onlySelfReposts: roundToTwo(onlySelfReposts),
1308 onlySelfRepostsPerDay: periodDays ? roundToTwo(onlySelfReposts / periodDays) : 0,
1309 onlyOtherReposts: roundToTwo(onlyOtherReposts),
1310 onlyOtherRepostsPerDay: periodDays ? roundToTwo(onlyOtherReposts / periodDays) : 0,
1311 postsWithImages: roundToTwo(postsWithImages),
1312 imagePostsPerDay: periodDays ? roundToTwo(postsWithImages / periodDays) : 0,
1313 imagePostsAltText: roundToTwo(imagePostsAltText),
1314 imagePostsNoAltText: roundToTwo(imagePostsNoAltText),
1315 altTextPercentage: roundToTwo(altTextPercentage),
1316 imagePostsReplies: roundToTwo(imagePostsReplies),
1317 postsWithOnlyText: roundToTwo(postsWithOnlyText),
1318 textPostsPerDay: periodDays ? roundToTwo(postsWithOnlyText / periodDays) : 0,
1319 postsWithMentions: roundToTwo(postsWithMentions),
1320 mentionPostsPerDay: periodDays ? roundToTwo(postsWithMentions / periodDays) : 0,
1321 postsWithVideo: roundToTwo(postsWithVideo),
1322 videoPostsPerDay: periodDays ? roundToTwo(postsWithVideo / periodDays) : 0,
1323 postsWithLinks: roundToTwo(postsWithLinks),
1324 linkPostsPerDay: periodDays ? roundToTwo(postsWithLinks / periodDays) : 0,
1325
1326 // Percentages
1327 replyPercentage: postsCount ? roundToTwo(onlyReplies / postsCount) : 0,
1328 replySelfPercentage: postsCount ? roundToTwo(onlyRepliesToSelf / postsCount) : 0,
1329 replyOtherPercentage: postsCount ? roundToTwo(onlyRepliesToOthers / postsCount) : 0,
1330 quotePercentage: postsCount ? roundToTwo(onlyQuotes / postsCount) : 0,
1331 quoteSelfPercentage: postsCount ? roundToTwo(onlySelfQuotes / postsCount) : 0,
1332 quoteOtherPercentage: postsCount ? roundToTwo(onlyOtherQuotes / postsCount) : 0,
1333 repostPercentage: postsCount ? roundToTwo(onlyReposts / postsCount) : 0,
1334 repostSelfPercentage: postsCount ? roundToTwo(onlySelfReposts / postsCount) : 0,
1335 repostOtherPercentage: postsCount ? roundToTwo(onlyOtherReposts / postsCount) : 0,
1336 textPercentage: postsCount ? roundToTwo(postsWithOnlyText / postsCount) : 0,
1337 linkPercentage: postsCount ? roundToTwo(postsWithLinks / postsCount) : 0,
1338 imagePercentage: postsCount ? roundToTwo(postsWithImages / postsCount) : 0,
1339 videoPercentage: postsCount ? roundToTwo(postsWithVideo / postsCount) : 0,
1340 };
1341
1342 return postStats;
1343}