This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

at master 51 kB View raw
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. 6async 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. 16async 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}