Something went wrong. Try again.
add or update alt text on your media forked from psingletary.com/verifier
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344/*********************************************************************** * New functions to resolve handle and service endpoint ***********************************************************************/
// Resolve a handle (e.g., "dame.bsky.social") into a DID using the atproto resolveHandle endpoint.export async function resolveHandleToDid(inputHandle) { const url = `${publicServiceEndpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(inputHandle)}`; const data = await getJSON(url); if (!data.did) { throw new Error("Could not resolve handle to DID."); } return data.did;}
// Get the service endpoint for the DID by querying either the PLC directory or (for did:web identities) the well-known DID document.export async function getServiceEndpointForDid(resolvedDid) { let url; if (resolvedDid.startsWith("did:web:")) { // For did:web, remove the prefix to get the domain. // (Example: "did:web:example.com" → "example.com") const domain = resolvedDid.slice("did:web:".length); // Construct the URL for the well-known DID document. url = `https://${domain}/.well-known/did.json`; } else if (resolvedDid.startsWith("did:plc:")) { // For did:plc, use your PLC directory endpoint. url = `${plcDirectoryEndpoint}/${encodeURIComponent(resolvedDid)}`; } else { throw new Error(`Unsupported DID method for DID: ${resolvedDid}`); }
// Fetch the DID document const data = await getJSON(url); if (!data.service || !Array.isArray(data.service)) { throw new Error("Could not determine service endpoint for DID."); }
// Look for the service endpoint. // For PLC DIDs, we specifically look for the service with type "AtprotoPersonalDataServer". // For did:web, we try to pick that service if present, otherwise fallback to the first available entry. let svcEntry; if (resolvedDid.startsWith("did:plc:")) { svcEntry = data.service.find((svc) => svc.type === "AtprotoPersonalDataServer"); } else if (resolvedDid.startsWith("did:web:")) { svcEntry = data.service.find((svc) => svc.type === "AtprotoPersonalDataServer") || data.service[0]; }
if (!svcEntry || !svcEntry.serviceEndpoint) { throw new Error("Could not determine service endpoint for DID."); } return svcEntry.serviceEndpoint;}
/*********************************************************************** * Global settings and basic caching ***********************************************************************/let did = null; // Will be resolved from the handle.let handle = null; // Will be set by the caller (from the URL/searchbar).let serviceEndpoint = null; // Will be derived from the PLC Directory.const plcDirectoryEndpoint = "https://plc.directory";const publicServiceEndpoint = "https://public.api.bsky.app";
// Basic in-memory cache to avoid duplicate API calls.const cache = {};
/*********************************************************************** * Helper Functions ***********************************************************************/async function getJSON(url) { try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status} error for ${url}`); } return await response.json(); } catch (err) { console.error("Error in getJSON for", url, err); throw err; }}
async function cachedGetJSON(url) { if (cache[url]) return cache[url]; const data = await getJSON(url); cache[url] = data; return data;}
/*********************************************************************** * NEW: Helper Function to Send Account Data to Backend API for Scoring ***********************************************************************/async function fetchScores(accountData) { try { const response = await fetch('https://api.cred.blue/api/score', { // Update URL when ready publicly method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(accountData) }); if (!response.ok) { throw new Error(`Error: ${response.statusText}`); } return await response.json(); } catch (error) { console.error("Error fetching scores:", error); throw error; }}
/*********************************************************************** * Utility Function to Find the First "createdAt" in a Record ***********************************************************************/// This function recursively searches for the first occurrence of "createdAt" in an object.function findFirstCreatedAt(obj) { if (typeof obj !== 'object' || obj === null) return null; if ('createdAt' in obj) return obj.createdAt; for (const key of Object.keys(obj)) { const value = obj[key]; if (typeof value === 'object' && value !== null) { const result = findFirstCreatedAt(value); if (result) return result; } } return null;}
/*********************************************************************** * Endpoint calls with pagination and caching ***********************************************************************/
// 1. Fetch Profile data (one-shot)async function fetchProfile() { const url = `${publicServiceEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`; return await cachedGetJSON(url);}
// 2. Fetch all blobs (paginated)async function fetchAllBlobsCount(onPage = () => {}, expectedPages = 2, cutoffTime = null) { let urlBase = `${serviceEndpoint}/xrpc/com.atproto.sync.listBlobs?did=${encodeURIComponent(did)}&limit=1000`; let count = 0, cursor = null; do { const url = urlBase + (cursor ? `&cursor=${cursor}` : ""); let data; try { data = await cachedGetJSON(url); } catch (err) { console.error("Error fetching blobs:", err); break; } if (Array.isArray(data.cids)) { for (const cid of data.cids) { if (data.blobs && data.blobs[cid]) { const blob = data.blobs[cid]; const createdAt = findFirstCreatedAt(blob); if (cutoffTime) { if (createdAt) { const recordTime = new Date(createdAt).getTime(); if (recordTime >= cutoffTime) { count += 1; } } else { // No 'createdAt', include it count += 1; } } else { count += 1; } } else { // If blob details aren't available, count it by default count += 1; } } } // Wait a tick before next page await new Promise((resolve) => setTimeout(resolve, 0)); cursor = data.cursor || null; } while (cursor); return count;}
// 3. Fetch repo description (one-shot)async function fetchRepoDescription() { const url = `${serviceEndpoint}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`; return await cachedGetJSON(url);}
async function fetchRecordsForCollection( collectionName, onPage = () => {}, expectedPages = 50, // Default cutoff: 90 days ago in ms cutoffTime = Date.now() - 90 * 24 * 60 * 60 * 1000) { console.log(`\n=== fetchRecordsForCollection: ${collectionName} (cutoff=${new Date(cutoffTime).toISOString()}) ===`); const urlBase = `${serviceEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent( did )}&collection=${encodeURIComponent(collectionName)}&limit=100`;
let records = []; let cursor = null; let pageCount = 0;
while (true) { pageCount++; const url = cursor ? `${urlBase}&cursor=${cursor}` : urlBase; console.log(`Fetching page #${pageCount} for ${collectionName} with URL:`, url);
let data; try { data = await cachedGetJSON(url); } catch (err) { console.error("Error fetching records for collection:", collectionName, err); break; }
if (!data || !Array.isArray(data.records) || data.records.length === 0) { console.log(`No more records returned for ${collectionName}; stopping.`); break; // no more data }
let minCreatedAt = Infinity; const pageRecords = [];
// 1) Inspect each record in the current page for (const rec of data.records) { // Attempt to find createdAt let createdAt; if (collectionName === "app.bsky.feed.post" && rec.value?.createdAt) { createdAt = rec.value.createdAt; } else { createdAt = findFirstCreatedAt(rec); }
// 2) Convert to ms, or fallback to a "current" time for missing createdAt let recordTime; if (!createdAt) { recordTime = Date.now(); // If missing, you could treat it as "include always" console.log( `Record with no createdAt => using "now" to compare. URI: ${rec.uri}` ); } else { recordTime = new Date(createdAt).getTime(); }
// 4) Track minimum createdAt in this page minCreatedAt = Math.min(minCreatedAt, recordTime);
// 5) We'll decide to include this record only if it's >= cutoffTime if (recordTime >= cutoffTime) { pageRecords.push(rec); } }
// 6) After analyzing all records in the page, if the earliest record is older than cutoff => no more pages if (minCreatedAt < cutoffTime) { console.log( `Found an older record in this page (minCreatedAt=${new Date( minCreatedAt ).toISOString()}); not fetching further pages.` ); // We still add the records that were >= cutoffTime from this page records.push(...pageRecords); break; }
// 7) If all records in this page are >= cutoffTime, add them all records.push(...pageRecords);
// 8) If there's no cursor => done if (!data.cursor) { console.log(`No cursor in response; done fetching for ${collectionName}.`); break; } else { // Move to next page cursor = data.cursor; }
// Optional: If we want to avoid infinite loops if (pageCount >= expectedPages) { console.log(`Reached expectedPages (${expectedPages}) for ${collectionName}; stopping.`); break; } }
console.log( `Finished fetchRecordsForCollection(${collectionName}), got total = ${records.length} records` ); return records;}
// 5. Fetch audit log from PLC Directory (one-shot)async function fetchAuditLog() { const url = `${plcDirectoryEndpoint}/${encodeURIComponent(did)}/log/audit`; return await cachedGetJSON(url);}
async function fetchAuthorFeed( onPage = () => {}, expectedPages = 10, cutoffTime = Date.now() - 90 * 24 * 60 * 60 * 1000) { console.log(`\n=== fetchAuthorFeed (cutoff=${new Date(cutoffTime).toISOString()}) ===`); const urlBase = `${publicServiceEndpoint}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent( did )}&limit=100`;
let feed = []; let cursor = null; let pageCount = 0;
while (true) { pageCount++; const url = cursor ? `${urlBase}&cursor=${cursor}` : urlBase; console.log(`Fetching page #${pageCount} with URL:`, url);
let data; try { data = await cachedGetJSON(url); } catch (err) { console.error("Error fetching author feed:", err); break; }
if (!data || !Array.isArray(data.feed) || data.feed.length === 0) { console.log("No more feed items returned; stopping."); break; }
let minCreatedAt = Infinity; const pageItems = [];
for (const item of data.feed) { // Only look at the main post's createdAt, not any nested reply data let createdAt = item.post?.record?.createdAt; let itemTime; if (!createdAt) { itemTime = Date.now(); console.log( `Feed item with no createdAt => using now. Post URI: ${item.post?.uri}` ); } else { itemTime = new Date(createdAt).getTime(); }
console.log( `Feed item with createdAt=${createdAt}, itemTime=${itemTime}, cutoffTime=${cutoffTime}, URI=${item.post?.uri}` ); minCreatedAt = Math.min(minCreatedAt, itemTime);
if (itemTime >= cutoffTime) { pageItems.push(item); } }
if (minCreatedAt < cutoffTime) { console.log( `Encountered a main post older than cutoff in this page (minCreatedAt=${new Date( minCreatedAt ).toISOString()}). Stopping further pagination.` ); feed.push(...pageItems); break; }
feed.push(...pageItems);
if (!data.cursor) { console.log(`No cursor in response; done fetching feed.`); break; } else { cursor = data.cursor; }
if (pageCount >= expectedPages) { console.log(`Hit expectedPages=${expectedPages}; stopping feed fetch.`); break; } }
console.log(`Finished fetchAuthorFeed. Got total = ${feed.length} items.`); return feed;}
/*********************************************************************** * Calculation Functions ***********************************************************************/function roundToTwo(num) { return Number(num.toFixed(2));}
function roundNumbers(obj) { if (Array.isArray(obj)) { return obj.map(roundNumbers); } else if (typeof obj === "object" && obj !== null) { const newObj = {}; for (let key in obj) { newObj[key] = roundNumbers(obj[key]); } return newObj; } else if (typeof obj === "number") { return roundToTwo(obj); } else { return obj; }}
function calculateAge(createdAt) { const created = new Date(createdAt); const today = new Date(); const diffTime = Math.abs(today - created); const ageInDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); const refDate = new Date("2022-11-17T00:35:16.391Z"); const daysSinceRef = Math.floor(Math.abs(today - refDate) / (1000 * 60 * 60 * 24)); const agePercentage = daysSinceRef > 0 ? ageInDays / daysSinceRef : 0; return { ageInDays, agePercentage };}
function calculatePostingStyle(stats) { const { onlyPostsPerDay = 0, replyOtherPercentage = 0, textPercentage = 0, imagePercentage = 0, videoPercentage = 0, linkPercentage = 0, altTextPercentage = 0, postsPerDay = 0, } = stats;
// Check for lurker first if (postsPerDay < 0.1 && stats.totalBskyRecordsPerDay > 0.3) { return "Lurker"; }
// If posting regularly (removed engagement check) if (onlyPostsPerDay > 0.8) { if (textPercentage > linkPercentage && textPercentage > imagePercentage && textPercentage > videoPercentage) { return "Text Poster"; } if (imagePercentage > linkPercentage && imagePercentage > textPercentage && imagePercentage > videoPercentage) { return altTextPercentage <= 0.3 ? "Image Poster whose working on using alt text" : "Image Poster"; } if (linkPercentage > imagePercentage && linkPercentage > textPercentage && linkPercentage > videoPercentage) { return "Link Poster"; } if (videoPercentage > imagePercentage && videoPercentage > textPercentage && videoPercentage > linkPercentage) { return "Video Poster"; } return "Mixed Content Poster"; }
// Special interaction types if (replyOtherPercentage >= 0.5) return "Reply Guy"; if (stats.quoteOtherPercentage >= 0.5) return "Quote Guy"; if (stats.repostOtherPercentage >= 0.5) return "Repost Guy"; return "Unknown";}
// 1. First, add this new function to calculate engagement ratefunction calculateEngagementMetrics(engagementsReceived = {}, onlyPosts = 0, followersCount = 0) { // Ensure we have valid numbers, defaulting to 0 if undefined const totalEngagements = ( (engagementsReceived?.likesReceived || 0) + (engagementsReceived?.repostsReceived || 0) + (engagementsReceived?.quotesReceived || 0) + (engagementsReceived?.repliesReceived || 0) );
const safePostsCount = Number(onlyPosts) || 0; const safeFollowersCount = Number(followersCount) || 0;
return { totalEngagements: roundToTwo(totalEngagements), engagementsPerPost: safePostsCount > 0 ? roundToTwo(totalEngagements / safePostsCount) : 0, engagementRate: (safePostsCount > 0 && safeFollowersCount > 0) ? roundToTwo((totalEngagements / (safePostsCount * safeFollowersCount)) * 100) : 0 };}
function calculateSocialStatus({ ageInDays = 0, followersCount = 0, followsCount = 0, engagementRate = 0 }) { // Define the minimum engagement rate threshold for advancing to higher tiers const MIN_ENGAGEMENT_RATE = 0.01; // 1% // Check for Newcomer first (less than 30 days old) if (ageInDays < 30) { return "Newcomer"; } // Default status for accounts older than 30 days let status = "Explorer"; // Check follower counts and engagement rate for higher tiers if (followersCount >= 100000) { if (engagementRate >= MIN_ENGAGEMENT_RATE) { status = "Leader"; } else { // Fallback to Guide if engagement requirement not met status = "Guide"; } } else if (followersCount >= 10000) { if (engagementRate >= MIN_ENGAGEMENT_RATE) { status = "Guide"; } else { // Fallback to Pathfinder if engagement requirement not met status = "Pathfinder"; } } else if (followersCount >= 1000) { if (engagementRate >= MIN_ENGAGEMENT_RATE) { status = "Pathfinder"; } // Fallback to Explorer if engagement requirement not met } // Add engagement qualifier based on rate if (engagementRate > 0.03) { // 3% return `Highly Engaging ${status}`; } else if (engagementRate > 0.01) { // 1% return `Engaging ${status}`; } // Return base status return status;}
function calculateActivityStatus(rate) { if (rate === 0) return "inactive"; if (rate > 0 && rate < 10) return "barely active"; if (rate >= 10 && rate < 25) return "active"; if (rate >= 25 && rate < 100) return "very active"; if (rate >= 100) return "extremely active";}
function calculateProfileCompletion(profile) { const hasDisplayName = Boolean(profile.displayName && profile.displayName.trim()); const hasBanner = Boolean(profile.banner && profile.banner.trim()); const hasDescription = Boolean(profile.description && profile.description.trim()); if (hasDisplayName && hasBanner && hasDescription) return "complete"; if (hasDisplayName || hasBanner || hasDescription) return "incomplete"; return "not started";}
function calculateDomainRarity(handle) { if (handle.includes("bsky.social")) { const len = handle.length; if (len >= 21) return "very common"; if (len >= 18 && len <= 20) return "common"; if (len === 17) return "uncommon"; if (len === 16) return "rare"; if (len === 15) return "very rare"; if (len <= 14) return "extremely rare"; } else { const standardTLDs = [".com", ".org", ".net"]; const hasStandardTLD = standardTLDs.some((tld) => handle.endsWith(tld)); let len; if (hasStandardTLD) { const parts = handle.split("."); const domainName = parts[0]; // Take just the domain name part before the TLD len = domainName.length; if (len >= 15) return "very common"; if (len >= 12 && len <= 14) return "common"; if (len >= 9 && len <= 11) return "uncommon"; if (len >= 7 && len <= 8) return "rare"; if (len === 6) return "very rare"; if (len <= 5) return "extremely rare"; } else { len = handle.length; if (len >= 14) return "very common"; if (len >= 11 && len <= 13) return "common"; if (len >= 8 && len <= 10) return "uncommon"; if (len >= 6 && len <= 7) return "rare"; if (len === 5) return "very rare"; if (len <= 4) return "extremely rare"; } } return "unknown";}
function calculateEra(createdAt) { const created = new Date(createdAt); if (created >= new Date("2022-11-16") && created <= new Date("2023-01-31")) { return "Pre-history"; } else if (created >= new Date("2023-02-01") && created <= new Date("2024-01-31")) { return "Invite-only"; } else if (created > new Date("2024-01-31")) { return "Public release"; } return "Unknown";}
/*********************************************************************** * Main Function – Build accountData90Days and accountData30Days JSON objects. ***********************************************************************/export async function loadAccountData(inputHandle, onProgress = () => {}) { try { // Validate input handle if (!inputHandle) throw new Error("Handle is not provided"); handle = inputHandle;
// Resolve handle to DID and get service endpoint did = await resolveHandleToDid(handle); serviceEndpoint = await getServiceEndpointForDid(did);
// Fetch profile const profile = await fetchProfile();
// Calculate age const { ageInDays, agePercentage } = calculateAge(profile.createdAt);
// Fetch blobs (all-time) const cutoffTimeAll = null; // No cutoff for all-time data const blobsCountAll = await fetchAllBlobsCount(() => {}, 10, cutoffTimeAll);
// Fetch repo description const repoDescription = await fetchRepoDescription(); let collections = repoDescription.collections || []; const totalCollections = collections.length; const bskyCollectionNames = collections.filter((col) => col.startsWith("app.bsky")); const totalBskyCollections = bskyCollectionNames.length; const totalNonBskyCollections = totalCollections - totalBskyCollections;
// Build targetCollections array const targetCollections = [...new Set(collections)];
// Parse audit log (or use defaults for did:web identities) let plcOperations; let totalAkas, totalBskyAkas, totalCustomAkas, rotationKeysRounded, activeAkasRounded;
if (did.startsWith("did:web:")) { // For did:web identities, skip fetching the audit log and use default values totalAkas = 1; totalBskyAkas = 0; totalCustomAkas = 1; rotationKeysRounded = 3; activeAkasRounded = 1; plcOperations = 0; } else { // For did:plc identities, fetch and process the audit log as usual const rawAuditData = await fetchAuditLog(); let auditRecords = Array.isArray(rawAuditData) ? rawAuditData : Object.values(rawAuditData); plcOperations = auditRecords.length; let rotationKeys = 0; let activeAkas = 0; let akaSet = new Set(); if (plcOperations > 0) { auditRecords.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); const latestRecord = auditRecords[auditRecords.length - 1]; if (latestRecord.operation && Array.isArray(latestRecord.operation.rotationKeys)) { rotationKeys = latestRecord.operation.rotationKeys.length; } if (latestRecord.operation && Array.isArray(latestRecord.operation.alsoKnownAs)) { activeAkas = latestRecord.operation.alsoKnownAs.length; } auditRecords.forEach((record) => { if (record.operation && Array.isArray(record.operation.alsoKnownAs)) { record.operation.alsoKnownAs.forEach((alias) => { akaSet.add(alias); }); } }); } totalAkas = akaSet.size; totalBskyAkas = Array.from(akaSet).filter((alias) => alias.includes("bsky.social") ).length; totalCustomAkas = roundToTwo(totalAkas - totalBskyAkas); rotationKeysRounded = roundToTwo(rotationKeys); activeAkasRounded = roundToTwo(activeAkas); }
// Define periods for 30 and 90 days const periods = [ { days: 30, label: "30Days" }, { days: 90, label: "90Days" }, ];
// Initialize objects to hold data for each period const accountDataPerPeriod = {};
for (const period of periods) { const { days, label } = period; const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; // Aggregate record counts for the period const { totalRecords, totalBskyRecords, totalNonBskyRecords, collectionStats, weeklyActivity } = await calculateRecordsAggregate(targetCollections, days, cutoffTime); const totalRecordsPerDay = days ? totalRecords / days : 0; const totalBskyRecordsPerDay = days ? totalBskyRecords / days : 0; const totalNonBskyRecordsPerDay = days ? totalNonBskyRecords / days : 0; // Fetch posts and reposts for the period and merge them const postsRecordsPosts = await fetchRecordsForCollection( "app.bsky.feed.post", () => {}, 20, cutoffTime ); const postsRecordsReposts = await fetchRecordsForCollection( "app.bsky.feed.repost", () => {}, 20, cutoffTime ); const postsRecords = postsRecordsPosts.concat(postsRecordsReposts); const postsCount = postsRecords.length; const postStats = computePostStats(postsRecords, days); // Compute engagements for the period and merge with postStats // First, calculate engagements const engagements = await calculateEngagements(cutoffTime);
// Create complete post stats const completePostStats = { ...postStats, likesReceived: engagements.likesReceived, repostsReceived: engagements.repostsReceived, quotesReceived: engagements.quotesReceived, repliesReceived: engagements.repliesReceived, engagementsReceived: { likesReceived: engagements.likesReceived, repostsReceived: engagements.repostsReceived, quotesReceived: engagements.quotesReceived, repliesReceived: engagements.repliesReceived, } }; // Compute activity statuses for the period const activityStatus = calculateActivityStatus(totalRecordsPerDay); const bskyActivityStatus = calculateActivityStatus(totalBskyRecordsPerDay); const atprotoActivityStatus = calculateActivityStatus(totalNonBskyRecordsPerDay); // Compute posting style for the period const postingStyle = calculatePostingStyle({ ...completePostStats, // Use complete stats here totalBskyRecordsPerDay, });
const engagementMetrics = calculateEngagementMetrics( completePostStats?.engagementsReceived || {}, postsCount || 0, profile?.followersCount || 0 ); const socialStatus = calculateSocialStatus({ ageInDays: ageInDays || 0, followersCount: profile?.followersCount || 0, followsCount: profile?.followsCount || 0, engagementRate: engagementMetrics?.engagementRate || 0 }); // Build analysis narrative for the period const narrative = buildAnalysisNarrative({ profile, activityAll: { activityStatus, bskyActivityStatus, atprotoActivityStatus, totalCollections: roundToTwo(totalCollections), totalBskyCollections: roundToTwo(totalBskyCollections), totalNonBskyCollections: roundToTwo(totalNonBskyCollections), totalRecords: roundToTwo(totalRecords), totalRecordsPerDay: roundToTwo(totalRecordsPerDay), totalBskyRecords: roundToTwo(totalBskyRecords), totalBskyRecordsPerDay: roundToTwo(totalBskyRecordsPerDay), totalBskyRecordsPercentage: totalRecords ? roundToTwo(totalBskyRecords / totalRecords) : 0, totalNonBskyRecords: roundToTwo(totalNonBskyRecords), totalNonBskyRecordsPerDay: roundToTwo(totalNonBskyRecords / days), plcOperations: roundToTwo(plcOperations), ...collectionStats, "app.bsky.feed.post": completePostStats, // Use complete stats here blobsCount: roundToTwo(blobsCountAll), blobsPerDay: ageInDays ? roundToTwo(blobsCountAll / ageInDays) : 0, blobsPerPost: postsCount ? roundToTwo(blobsCountAll / postsCount) : 0, blobsPerImagePost: completePostStats.postsWithImages ? roundToTwo(blobsCountAll / completePostStats.postsWithImages) : 0, }, postingStyle, socialStatus, rotationKeys: rotationKeysRounded, engagementMetrics, // Make sure this is passed alsoKnownAs: { totalAkas: roundToTwo(totalAkas), totalCustomAkas: roundToTwo(totalCustomAkas), totalBskyAkas: roundToTwo(totalBskyAkas), }, }); // Build the account data object for this period. let periodData = { // Keep all the basic profile info profile: { ...profile, did: profile.did || did, }, displayName: profile.displayName, handle: profile.handle, did: profile.did || did, profileEditedDate: profile.indexedAt, profileCompletion: calculateProfileCompletion(profile), // Maintain the activityAll structure for backward compatibility activityAll: { activityStatus, bskyActivityStatus, atprotoActivityStatus, totalCollections: roundToTwo(totalCollections), totalBskyCollections: roundToTwo(totalBskyCollections), totalNonBskyCollections: roundToTwo(totalNonBskyCollections), totalRecords: roundToTwo(totalRecords), totalRecordsPerDay: roundToTwo(totalRecordsPerDay), totalBskyRecords: roundToTwo(totalBskyRecords), totalBskyRecordsPerDay: roundToTwo(totalBskyRecordsPerDay), totalBskyRecordsPercentage: totalRecords ? roundToTwo(totalBskyRecords / totalRecords) : 0, totalNonBskyRecords: roundToTwo(totalNonBskyRecords), totalNonBskyRecordsPerDay: roundToTwo(totalNonBskyRecords / days), totalNonBskyRecordsPercentage: totalRecords ? roundToTwo(totalNonBskyRecords / totalRecords) : 0, plcOperations: roundToTwo(plcOperations), ...collectionStats, "app.bsky.feed.post": completePostStats, blobsCount: roundToTwo(blobsCountAll), blobsPerDay: ageInDays ? roundToTwo(blobsCountAll / ageInDays) : 0, blobsPerPost: postsCount ? roundToTwo(blobsCountAll / postsCount) : 0, blobsPerImagePost: completePostStats.postsWithImages ? roundToTwo(blobsCountAll / completePostStats.postsWithImages) : 0, }, // Add new categorical structure blueskyCategories: { profileQuality: { profileCompleteness: { avatar: profile.avatar ? true : false, banner: profile.banner ? true : false, description: profile.description ? true : false, pinnedPost: profile.pinnedPost ? true : false }, altTextConsistency: completePostStats.altTextPercentage || 0, customDomain: !profile.handle.includes("bsky.social"), score: 0, }, communityEngagement: { socialGraph: { followersCount: profile.followersCount, followsCount: profile.followsCount, followRatio: profile.followersCount ? (profile.followsCount / profile.followersCount) : 0 }, engagement: { ...engagementMetrics, replyRate: completePostStats.replyOtherPercentage || 0 }, score: 0, }, contentActivity: { posts: { totalBskyRecords: totalBskyRecords, postsPerDay: totalBskyRecordsPerDay, collections: totalBskyCollections }, contentQuality: { labels: profile.labels || [], postStats: completePostStats }, score: 0, }, recognitionStatus: { accountAge: { ageInDays: ageInDays, agePercentage: agePercentage, era: calculateEra(profile.createdAt) }, status: { socialStatus: socialStatus, postingStyle: postingStyle }, score: 0, } }, atprotoCategories: { decentralization: { pds: { serviceEndpoint, isThirdParty: !serviceEndpoint.includes("bsky.network"), pdsType: serviceEndpoint.includes("bsky.network") ? "Bluesky" : "Third-party" }, identity: { did: profile.did || did, isDidWeb: (profile.did || did).startsWith("did:web"), rotationKeys: rotationKeysRounded, customDomain: !profile.handle.includes("bsky.social") }, score: 0, }, protocolActivity: { collections: { totalNonBskyCollections, totalNonBskyRecords, recordsPerDay: totalNonBskyRecordsPerDay }, score: 0, }, accountMaturity: { age: { ageInDays, agePercentage, createdAt: profile.createdAt }, plcOperations: plcOperations, score: 0, } }, // Keep other necessary metadata fields serviceEndpoint, pdsType: serviceEndpoint.includes("bsky.network") ? "Bluesky" : "Third-party", createdAt: profile.createdAt, ageInDays: roundToTwo(ageInDays), agePercentage: roundToTwo(agePercentage), followersCount: roundToTwo(profile.followersCount), followsCount: roundToTwo(profile.followsCount), postsCount: roundToTwo(postsCount), rotationKeys: rotationKeysRounded, era: calculateEra(profile.createdAt), postingStyle, socialStatus, engagementMetrics, weeklyActivity, analysis: { narrative: { narrative1: narrative.narrative1, narrative2: narrative.narrative2, narrative3: narrative.narrative3, } } };
// Send the account data object to the backend scoring API periodData = await fetchScores(periodData); accountDataPerPeriod[`accountData${label}`] = periodData; }
// Build final output JSON. const finalOutput = { message: "accountData retrieved successfully", accountData90Days: accountDataPerPeriod.accountData90Days, accountData30Days: accountDataPerPeriod.accountData30Days, };
return roundNumbers(finalOutput); } catch (err) { console.error("Error loading account data:", err); return { message: "Error retrieving accountData", error: err.toString(), }; }}
/*********************************************************************** * Additional Helper Functions (if any) ***********************************************************************/
// Build the analysis narrative paragraphs.function buildAnalysisNarrative(accountData) { const { profile, activityAll, alsoKnownAs, engagementMetrics = {} } = accountData; const { agePercentage } = calculateAge(profile.createdAt); let accountAgeStatement = ""; if (agePercentage >= 0.97) { accountAgeStatement = "since the very beginning and is"; } else if (agePercentage >= 0.7) { accountAgeStatement = "for a very long time and is"; } else if (agePercentage >= 0.5) { accountAgeStatement = "for a long time and is"; } else if (agePercentage >= 0.1) { accountAgeStatement = "for awhile and is"; } else if (agePercentage >= 0.02) { accountAgeStatement = "for only a short period of time and is"; } else { accountAgeStatement = "for barely any time at all"; }
const totalBskyCollections = activityAll.totalBskyCollections || 0; let blueskyFeatures = ""; if (totalBskyCollections >= 12) { blueskyFeatures = "they are using all of Bluesky's core features"; } else if (totalBskyCollections >= 8) { blueskyFeatures = "they are using most of Bluesky’s core features"; } else if (totalBskyCollections >= 3) { blueskyFeatures = "they are using some of Bluesky’s core features"; } else { blueskyFeatures = "they haven't used any of Bluesky's core features yet"; }
const totalNonBskyCollections = activityAll.totalNonBskyCollections || 0; const totalNonBskyRecords = activityAll.totalNonBskyRecords || 0; let atprotoEngagement = ""; if (totalNonBskyCollections >= 10 && totalNonBskyRecords > 100) { atprotoEngagement = "is extremely engaged, having used many different services or tools"; } else if (totalNonBskyCollections >= 5 && totalNonBskyRecords > 50) { atprotoEngagement = "is very engaged, having used many different services or tools"; } else if (totalNonBskyCollections > 0 && totalNonBskyRecords > 5) { atprotoEngagement = "has dipped their toes in the water, but has yet to go deeper"; } else { atprotoEngagement = "has not yet explored what's out there"; }
let domainHistoryStatement = ""; if (alsoKnownAs.totalCustomAkas > 0 && profile.handle.includes("bsky.social")) { domainHistoryStatement = "They've used a custom domain name at some point but are currently using a default Bluesky handle"; } else if (!profile.handle.includes("bsky.social")) { domainHistoryStatement = "They currently are using a custom domain"; } else if (alsoKnownAs.totalAkas > 2 && !profile.handle.includes("bsky.social")) { domainHistoryStatement = "They have a custom domain set and have a history of using different aliases"; } else { domainHistoryStatement = "They still have a default Bluesky handle"; }
// Debug logging to see what values we're working with console.log('Root rotationKeys:', accountData.rotationKeys); console.log('Nested rotationKeys:', accountData.atprotoCategories?.decentralization?.identity?.rotationKeys); const rotationKeyCount = accountData.rotationKeys || 0; console.log('Final rotationKeyCount used:', rotationKeyCount); let rotationKeyStatement = (rotationKeyCount === 1 || rotationKeyCount >= 3) ? "They have their own rotation key set" : "They don't have their own rotation key set"; console.log('Generated statement:', rotationKeyStatement);
let pdsHostStatement = serviceEndpoint.includes("bsky.network") ? "their PDS is hosted by a Bluesky mushroom" : "their PDS is hosted by either a third-party or themselves";
// First Paragraph const narrative1 = `${profile.displayName} has been on the network ${accountAgeStatement} ${calculateActivityStatus(activityAll.totalRecordsPerDay)}. ` + `Their profile is ${calculateProfileCompletion(profile)}, and ${blueskyFeatures}. ` + `When it comes to the broader ATProto ecosystem, this identity ${atprotoEngagement}.`;
// Second Paragraph const narrative2 = `${domainHistoryStatement} which has a rarity level of ${calculateDomainRarity(profile.handle)}. ` + `${rotationKeyStatement}, and ${pdsHostStatement}.`;
const era = calculateEra(profile.createdAt); const postingStyle = accountData.postingStyle || "Unknown"; const socialStatus = accountData.socialStatus || "Community Member"; // Safely access engagement metrics with defaults const { engagementRate = 0, engagementsPerPost = 0 } = engagementMetrics; let engagementPhrase = ""; if (engagementRate > 0.03) { engagementPhrase = "with exceptionally high engagement"; } else if (engagementRate > 0.01) { engagementPhrase = "with very high engagement"; } else if (engagementRate > 0.005) { engagementPhrase = "with high engagement"; } else { engagementPhrase = "with normal engagement"; } const narrative3 = `${profile.displayName || "This user"} first joined Bluesky during the ${era} era. ` + `Their style of posting is "${postingStyle}". ` + `They are a "${socialStatus}" ${engagementPhrase}, ` + `averaging ${engagementsPerPost.toFixed(1)} engagements per post ` + `across their follower base of ${profile.followersCount || 0}.`; return { narrative1, narrative2, narrative3 };}
/*********************************************************************** * Function to calculate aggregate records for the account by iterating over each collection. ***********************************************************************/async function calculateRecordsAggregate(collectionNames, periodDays, cutoffTime) { let totalRecords = 0; let totalBskyRecords = 0; let totalNonBskyRecords = 0; const collectionStats = {}; // Initialize weekly data structure const weeklyData = Array.from({ length: Math.ceil(periodDays / 7) }, (_, index) => ({ weekNumber: index, bskyRecords: 0, nonBskyRecords: 0 }));
const getWeekNumber = (date) => { const recordDate = new Date(date); const cutoff = new Date(cutoffTime); const diffTime = Math.abs(recordDate - cutoff); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return Math.floor(diffDays / 7); };
for (const col of collectionNames) { const recs = await fetchRecordsForCollection(col, () => {}, 50, cutoffTime); const count = recs.length; const perDay = periodDays ? count / periodDays : 0; // Process each record for weekly stats for (const record of recs) { const createdAt = record.value?.createdAt || findFirstCreatedAt(record); if (createdAt) { const weekNum = getWeekNumber(createdAt); if (weekNum >= 0 && weekNum < weeklyData.length) { if (col.startsWith("app.bsky")) { weeklyData[weekNum].bskyRecords++; } else { weeklyData[weekNum].nonBskyRecords++; } } } }
collectionStats[col] = { count: roundToTwo(count), perDay: roundToTwo(perDay), }; totalRecords += count; if (col.startsWith("app.bsky")) { totalBskyRecords += count; } else { totalNonBskyRecords += count; } }
return { totalRecords, totalBskyRecords, totalNonBskyRecords, collectionStats, weeklyActivity: weeklyData };}
/*********************************************************************** * Function to calculate engagements for the account using the author feed. ***********************************************************************/async function calculateEngagements(cutoffTime = null) { console.log("Starting engagement calculation"); const feed = await fetchAuthorFeed(() => {}, 15, cutoffTime); let likesReceived = 0; let repostsReceived = 0; let quotesReceived = 0; let repliesReceived = 0;
for (const item of feed) { // Only consider posts from this author if (item && item.post && item.post.author && item.post.author.did === did) { // Only add the direct post metrics, ignoring any nested metrics if (item.post.likeCount !== undefined) likesReceived += item.post.likeCount; if (item.post.repostCount !== undefined) repostsReceived += item.post.repostCount; if (item.post.quoteCount !== undefined) quotesReceived += item.post.quoteCount; if (item.post.replyCount !== undefined) repliesReceived += item.post.replyCount; } }
const results = { likesReceived: roundToTwo(likesReceived), repostsReceived: roundToTwo(repostsReceived), quotesReceived: roundToTwo(quotesReceived), repliesReceived: roundToTwo(repliesReceived), };
return results;}
/*********************************************************************** * Function to compute post statistics based on records and period ***********************************************************************/function computePostStats(postsRecords, periodDays) { function filterRecords(records, testFunc) { return records.filter(testFunc).length; }
const postsCount = postsRecords.length; const onlyPosts = filterRecords(postsRecords, (rec) => !rec.value.hasOwnProperty("reply")); const onlyReplies = filterRecords(postsRecords, (rec) => rec.value.hasOwnProperty("reply")); const onlyRepliesToSelf = postsRecords.filter((rec) => { if (!rec.value || !rec.value.reply || !rec.value.reply.parent) return false; return rec.value.reply.parent.uri.includes(did); }).length; const onlyRepliesToOthers = onlyReplies - onlyRepliesToSelf; const onlyQuotes = filterRecords( postsRecords, (rec) => rec.value.embed && rec.value.embed["$type"] === "app.bsky.embed.record" ); const onlySelfQuotes = filterRecords(postsRecords, (rec) => { if ( !rec.value || !rec.value.embed || (rec.value.embed["$type"] !== "app.bsky.embed.record" && rec.value.embed["$type"] !== "app.bsky.embed.recordWithMedia") ) { return false; } const embedRecord = rec.value.embed.record; return ( (embedRecord.record && embedRecord.record.uri && embedRecord.record.uri.includes(did)) || (embedRecord.uri && embedRecord.uri.includes(did)) ); }); const onlyOtherQuotes = onlyQuotes - onlySelfQuotes;
// Reposts const repostRecords = postsRecords.filter((rec) => rec.value["$type"] === "app.bsky.feed.repost"); const onlyReposts = repostRecords.length; const onlySelfReposts = filterRecords(repostRecords, (rec) => { if (!rec.value || !rec.value.subject || !rec.value.subject.uri) return false; return rec.value.subject.uri.includes(did); }); const onlyOtherReposts = onlyReposts - onlySelfReposts;
// Images and related stats const postsWithImages = filterRecords( postsRecords, (rec) => rec.value.embed && rec.value.embed["$type"] === "app.bsky.embed.images" ); const imagePostsAltText = filterRecords(postsRecords, (rec) => { if (!rec.value.embed || rec.value.embed["$type"] !== "app.bsky.embed.images") { return false; } return ( rec.value.embed.images && rec.value.embed.images.some((image) => image.alt && image.alt.trim()) ); }); // Compute the count of image posts (with alt text) that are replies. const imagePostsReplies = filterRecords(postsRecords, (rec) => { const isImagePostWithAlt = rec.value.embed && rec.value.embed["$type"] === "app.bsky.embed.images" && rec.value.embed.images && rec.value.embed.images.some((img) => img.alt && img.alt.trim()); return isImagePostWithAlt && rec.value.reply; }); const imagePostsNoAltText = postsWithImages - imagePostsAltText; const altTextPercentage = postsWithImages ? imagePostsAltText / postsWithImages : 0; const postsWithOnlyText = filterRecords( postsRecords, (rec) => !rec.value.embed && !rec.value.reply && !(rec.value.facets && JSON.stringify(rec.value.facets).includes("app.bsky.richtext.facet#link")) ); const postsWithMentions = filterRecords(postsRecords, (rec) => { if (!rec.value || !rec.value.facets) return false; return rec.value.facets.some((facet) => facet.features && facet.features.some((feature) => feature["$type"] === "app.bsky.richtext.facet#mention") ); }); const postsWithVideo = filterRecords( postsRecords, (rec) => rec.value.embed && rec.value.embed["$type"] === "app.bsky.embed.video" ); const postsWithLinks = filterRecords(postsRecords, (rec) => { if ( rec.value.facets && rec.value.facets.features && rec.value.facets.features.some((f) => f["$type"] === "app.bsky.richtext.facet#link") ) return true; if (rec.value.embed && rec.value.embed["$type"] === "app.bsky.embed.external") return true; return false; });
const postStats = { postsCount: roundToTwo(postsCount), postsPerDay: periodDays ? roundToTwo(postsCount / periodDays) : 0, onlyPosts: roundToTwo(onlyPosts), onlyPostsPerDay: periodDays ? roundToTwo(onlyPosts / periodDays) : 0, onlyReplies: roundToTwo(onlyReplies), onlyRepliesPerDay: periodDays ? roundToTwo(onlyReplies / periodDays) : 0, onlyRepliesToSelf: roundToTwo(onlyRepliesToSelf), onlyRepliesToSelfPerDay: periodDays ? roundToTwo(onlyRepliesToSelf / periodDays) : 0, onlyRepliesToOthers: roundToTwo(onlyRepliesToOthers), onlyRepliesToOthersPerDay: periodDays ? roundToTwo(onlyRepliesToOthers / periodDays) : 0, onlyQuotes: roundToTwo(onlyQuotes), onlyQuotesPerDay: periodDays ? roundToTwo(onlyQuotes / periodDays) : 0, onlySelfQuotes: roundToTwo(onlySelfQuotes), onlySelfQuotesPerDay: periodDays ? roundToTwo(onlySelfQuotes / periodDays) : 0, onlyOtherQuotes: roundToTwo(onlyOtherQuotes), onlyOtherQuotesPerDay: periodDays ? roundToTwo(onlyOtherQuotes / periodDays) : 0, onlyReposts: roundToTwo(onlyReposts), onlyRepostsPerDay: periodDays ? roundToTwo(onlyReposts / periodDays) : 0, onlySelfReposts: roundToTwo(onlySelfReposts), onlySelfRepostsPerDay: periodDays ? roundToTwo(onlySelfReposts / periodDays) : 0, onlyOtherReposts: roundToTwo(onlyOtherReposts), onlyOtherRepostsPerDay: periodDays ? roundToTwo(onlyOtherReposts / periodDays) : 0, postsWithImages: roundToTwo(postsWithImages), imagePostsPerDay: periodDays ? roundToTwo(postsWithImages / periodDays) : 0, imagePostsAltText: roundToTwo(imagePostsAltText), imagePostsNoAltText: roundToTwo(imagePostsNoAltText), altTextPercentage: roundToTwo(altTextPercentage), imagePostsReplies: roundToTwo(imagePostsReplies), postsWithOnlyText: roundToTwo(postsWithOnlyText), textPostsPerDay: periodDays ? roundToTwo(postsWithOnlyText / periodDays) : 0, postsWithMentions: roundToTwo(postsWithMentions), mentionPostsPerDay: periodDays ? roundToTwo(postsWithMentions / periodDays) : 0, postsWithVideo: roundToTwo(postsWithVideo), videoPostsPerDay: periodDays ? roundToTwo(postsWithVideo / periodDays) : 0, postsWithLinks: roundToTwo(postsWithLinks), linkPostsPerDay: periodDays ? roundToTwo(postsWithLinks / periodDays) : 0,
// Percentages replyPercentage: postsCount ? roundToTwo(onlyReplies / postsCount) : 0, replySelfPercentage: postsCount ? roundToTwo(onlyRepliesToSelf / postsCount) : 0, replyOtherPercentage: postsCount ? roundToTwo(onlyRepliesToOthers / postsCount) : 0, quotePercentage: postsCount ? roundToTwo(onlyQuotes / postsCount) : 0, quoteSelfPercentage: postsCount ? roundToTwo(onlySelfQuotes / postsCount) : 0, quoteOtherPercentage: postsCount ? roundToTwo(onlyOtherQuotes / postsCount) : 0, repostPercentage: postsCount ? roundToTwo(onlyReposts / postsCount) : 0, repostSelfPercentage: postsCount ? roundToTwo(onlySelfReposts / postsCount) : 0, repostOtherPercentage: postsCount ? roundToTwo(onlyOtherReposts / postsCount) : 0, textPercentage: postsCount ? roundToTwo(postsWithOnlyText / postsCount) : 0, linkPercentage: postsCount ? roundToTwo(postsWithLinks / postsCount) : 0, imagePercentage: postsCount ? roundToTwo(postsWithImages / postsCount) : 0, videoPercentage: postsCount ? roundToTwo(postsWithVideo / postsCount) : 0, };
return postStats;}