Skip to content
← Public packages

@kentcdodds/x

X API v2 helpers for tweets, search, legacy DMs, and encrypted X Chat via a Fly XDK sidecar.

src/metrics.ts

123 lines · 3.8 KB · TypeScript
import type {
	JsonRecord,
	XPostMetricsInput,
	XPublicMetrics,
	XSummarizePostMetricsParams,
	XSummarizePostMetricsSort,
} from './types.ts'

function num(value: unknown): number {
	if (typeof value === 'number' && Number.isFinite(value)) return value
	if (typeof value === 'string' && value.trim() !== '') {
		const n = Number(value)
		if (Number.isFinite(n)) return n
	}
	return 0
}

function per1k(count: number, impressions: number): number | null {
	if (!impressions || impressions <= 0) return null
	return (count / impressions) * 1000
}

function median(values: number[]): number | null {
	if (!values.length) return null
	const sorted = [...values].sort((a, b) => a - b)
	const mid = Math.floor(sorted.length / 2)
	if (sorted.length % 2 === 0) return (sorted[mid - 1]! + sorted[mid]!) / 2
	return sorted[mid]!
}

export type XPostMetricsRow = {
	id: string | undefined
	impressions: number
	likes: number
	replies: number
	retweets: number
	bookmarks: number
	quotes: number
	likesPer1k: number | null
	bookmarksPer1k: number | null
	repliesPer1k: number | null
	quotesPer1k: number | null
	engagementPer1k: number | null
	engagement: number
	/** Follows / new followers are NOT in public_metrics. */
	followsPer1k: null
	unavailableReason: 'not_in_public_metrics'
}

export function metricsFromPost(post: XPostMetricsInput): XPostMetricsRow {
	const m = (post.public_metrics || {}) as XPublicMetrics
	const impressions = num(m.impression_count)
	const likes = num(m.like_count)
	const replies = num(m.reply_count)
	const retweets = num(m.retweet_count)
	const bookmarks = num(m.bookmark_count)
	const quotes = num(m.quote_count)
	const engagement = likes + replies + retweets + bookmarks + quotes
	return {
		id: typeof post.id === 'string' ? post.id : undefined,
		impressions,
		likes,
		replies,
		retweets,
		bookmarks,
		quotes,
		likesPer1k: per1k(likes, impressions),
		bookmarksPer1k: per1k(bookmarks, impressions),
		repliesPer1k: per1k(replies, impressions),
		quotesPer1k: per1k(quotes, impressions),
		engagementPer1k: per1k(engagement, impressions),
		engagement,
		followsPer1k: null,
		unavailableReason: 'not_in_public_metrics',
	}
}

function sortRows(rows: XPostMetricsRow[], sort: XSummarizePostMetricsSort): XPostMetricsRow[] {
	const key = sort
	return [...rows].sort((a, b) => {
		const av = a[key as keyof XPostMetricsRow]
		const bv = b[key as keyof XPostMetricsRow]
		const an = typeof av === 'number' ? av : -Infinity
		const bn = typeof bv === 'number' ? bv : -Infinity
		return bn - an
	})
}

/**
 * Pure engagement table from posts that already have `public_metrics`.
 * Engagement = likes + replies + retweets + bookmarks + quotes.
 * `followsPer1k` is always null — follows/new followers are not in public_metrics
 * (do not scrape Analytics UI; elevated Analytics API is out of scope here).
 */
export function summarizePostMetricsFromPosts(
	posts: XPostMetricsInput[],
	options: Pick<XSummarizePostMetricsParams, 'sort' | 'filter'> = {},
): JsonRecord {
	let rows = posts.map(metricsFromPost)
	if (options.filter === 'lowLikeHighImpression') {
		const withImp = rows.filter((r) => r.impressions > 0)
		const impMed = median(withImp.map((r) => r.impressions))
		const likeMed = median(
			withImp.map((r) => r.likesPer1k).filter((v): v is number => typeof v === 'number'),
		)
		if (impMed !== null && likeMed !== null) {
			rows = rows.filter(
				(r) =>
					r.impressions >= impMed &&
					typeof r.likesPer1k === 'number' &&
					r.likesPer1k < likeMed,
			)
		}
	}
	if (options.sort) rows = sortRows(rows, options.sort)
	return {
		rows,
		count: rows.length,
		engagementDefinition: 'likes + replies + retweets + bookmarks + quotes',
		followsNote:
			'followsPer1k / new followers are NOT in public_metrics; unavailableReason is always not_in_public_metrics. Do not scrape X Analytics UI.',
	}
}