← 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 · TypeScriptimport 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.',
}
}