Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/reddit

src/shape.ts

321 lines · 9.7 KB · TypeScript
import type {
	JsonRecord,
	ListingSort,
	SearchSort,
	ShapedComment,
	ShapedListing,
	ShapedPost,
	ShapedSubreddit,
	ShapedUser,
	SubmitKind,
	TimeFilter,
	UserListing,
	VoteDirection,
} from './types.ts'
import { asRecord, booleanValue, numberValue, stringValue } from './types.ts'

const SELFTEXT_LIMIT = 2000
const COMMENT_BODY_LIMIT = 2000

export function normalizeSubreddit(value: unknown, label = 'subreddit'): string {
	const raw = requireString(value, label + ' is required.')
	const trimmed = raw.replace(/^\/?(r\/)?/i, '').replace(/\/+$/, '')
	if (!trimmed) {
		throw new Error(label + ' is required.')
	}
	if (!/^[A-Za-z0-9_]+$/.test(trimmed)) {
		throw new Error(label + ' must be a Reddit community name.')
	}
	return trimmed
}

export function normalizeUsername(value: unknown): string {
	const raw = requireString(value, 'username is required.')
	const trimmed = raw.replace(/^\/?(u\/|user\/)/i, '').replace(/\/+$/, '')
	if (!trimmed) {
		throw new Error('username is required.')
	}
	if (!/^[A-Za-z0-9_-]+$/.test(trimmed)) {
		throw new Error('username must be a Reddit username.')
	}
	return trimmed
}

export function normalizeThingId(value: unknown, prefix: 't1' | 't3', label: string): string {
	const raw = requireString(value, label + ' is required.')
	if (raw.startsWith(prefix + '_')) return raw
	if (/^[A-Za-z0-9]+$/.test(raw)) return prefix + '_' + raw
	throw new Error(label + ' must be a Reddit ' + prefix + ' fullname or id.')
}

export function optionalLimit(value: unknown, fallback = 25): number {
	if (value === undefined || value === null || value === '') return fallback
	if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > 100) {
		throw new Error('limit must be an integer from 1 through 100.')
	}
	return value
}

export function listingSort(value: unknown, fallback: ListingSort = 'hot'): ListingSort {
	const sort = value === undefined || value === null || value === '' ? fallback : value
	switch (sort) {
		case 'best':
		case 'hot':
		case 'new':
		case 'top':
		case 'rising':
		case 'controversial':
			return sort
		default:
			throw new Error('sort must be best, hot, new, top, rising, or controversial.')
	}
}

export function searchSort(value: unknown, fallback: SearchSort = 'relevance'): SearchSort {
	const sort = value === undefined || value === null || value === '' ? fallback : value
	switch (sort) {
		case 'relevance':
		case 'hot':
		case 'top':
		case 'new':
		case 'comments':
			return sort
		default:
			throw new Error('sort must be relevance, hot, top, new, or comments.')
	}
}

export function timeFilter(value: unknown): TimeFilter | undefined {
	if (value === undefined || value === null || value === '') return undefined
	switch (value) {
		case 'hour':
		case 'day':
		case 'week':
		case 'month':
		case 'year':
		case 'all':
			return value
		default:
			throw new Error('t must be hour, day, week, month, year, or all.')
	}
}

export function userListing(value: unknown, fallback: UserListing = 'about'): UserListing {
	const listing = value === undefined || value === null || value === '' ? fallback : value
	switch (listing) {
		case 'about':
		case 'submitted':
		case 'comments':
		case 'overview':
			return listing
		default:
			throw new Error('listing must be about, submitted, comments, or overview.')
	}
}

export function submitKind(value: unknown, fallback: SubmitKind = 'self'): SubmitKind {
	const kind = value === undefined || value === null || value === '' ? fallback : value
	switch (kind) {
		case 'self':
		case 'link':
			return kind
		default:
			throw new Error('kind must be self or link.')
	}
}

export function voteDirection(value: unknown): VoteDirection {
	switch (value) {
		case 1:
		case '1':
		case 'up':
			return 1
		case 0:
		case '0':
		case 'clear':
			return 0
		case -1:
		case '-1':
		case 'down':
			return -1
		default:
			throw new Error("direction must be 1, 0, -1, 'up', 'down', or 'clear'.")
	}
}

export function requireString(value: unknown, message: string): string {
	if (typeof value !== 'string' || value.trim().length === 0) {
		throw new Error(message)
	}
	return value.trim()
}

export function requireConfirm(confirm: unknown, message: string): void {
	if (confirm !== true) {
		throw new Error(message)
	}
}

export function truncateText(value: string | null, limit: number): { text: string | null; truncated: boolean } {
	if (value === null) return { text: null, truncated: false }
	if (value.length <= limit) return { text: value, truncated: false }
	return { text: value.slice(0, limit), truncated: true }
}

export function listingPath(subreddit: string | undefined, sort: ListingSort): string {
	if (subreddit) return '/r/' + encodeURIComponent(subreddit) + '/' + sort
	return '/' + sort
}

export function shapeListing(body: unknown): ShapedListing {
	const listing = unwrapListing(body)
	return {
		kind: 'listing',
		after: stringValue(listing.after) ?? null,
		before: stringValue(listing.before) ?? null,
		posts: childrenOf(listing).flatMap((child) => {
			if (child.kind !== 't3') return []
			const post = shapePost(child.data)
			return post ? [post] : []
		}),
	}
}

export function shapeCommentListing(body: unknown): ShapedComment[] {
	const listing = unwrapListing(body)
	return flattenComments(childrenOf(listing))
}

export function shapePost(value: unknown): ShapedPost | null {
	const data = asRecord(value)
	if (!data) return null
	const selftext = truncateText(stringValue(data.selftext) ?? null, SELFTEXT_LIMIT)
	return {
		id: stringValue(data.id) ?? '',
		name: stringValue(data.name) ?? '',
		title: stringValue(data.title) ?? '',
		author: stringValue(data.author) ?? '',
		subreddit: stringValue(data.subreddit) ?? '',
		score: numberValue(data.score),
		upvoteRatio: numberValue(data.upvote_ratio),
		numComments: numberValue(data.num_comments),
		url: stringValue(data.url) ?? null,
		permalink: permalinkOf(data.permalink),
		createdUtc: numberValue(data.created_utc),
		over18: booleanValue(data.over_18),
		stickied: booleanValue(data.stickied),
		isSelf: booleanValue(data.is_self),
		selftext: selftext.text,
		selftextTruncated: selftext.truncated,
		linkFlairText: stringValue(data.link_flair_text) ?? null,
	}
}

export function shapeComment(value: unknown): ShapedComment | null {
	const data = asRecord(value)
	if (!data) return null
	const body = truncateText(stringValue(data.body) ?? null, COMMENT_BODY_LIMIT)
	return {
		id: stringValue(data.id) ?? '',
		name: stringValue(data.name) ?? '',
		author: stringValue(data.author) ?? '',
		body: body.text,
		bodyTruncated: body.truncated,
		score: numberValue(data.score),
		permalink: permalinkOf(data.permalink),
		createdUtc: numberValue(data.created_utc),
		parentId: stringValue(data.parent_id) ?? null,
	}
}

export function shapeSubreddit(body: unknown): ShapedSubreddit {
	const data = unwrapThingData(body)
	return {
		id: stringValue(data.id) ?? null,
		name: stringValue(data.name) ?? null,
		displayName: stringValue(data.display_name) ?? null,
		title: stringValue(data.title) ?? null,
		publicDescription: stringValue(data.public_description) ?? null,
		subscribers: numberValue(data.subscribers),
		accountsActive: numberValue(data.accounts_active) ?? numberValue(data.active_user_count),
		over18: booleanValue(data.over18) || booleanValue(data.over_18),
		url: stringValue(data.url) ?? null,
		createdUtc: numberValue(data.created_utc),
		submissionType: stringValue(data.submission_type) ?? null,
	}
}

export function shapeUser(body: unknown): ShapedUser {
	const data = unwrapThingData(body)
	return {
		id: stringValue(data.id) ?? null,
		name: stringValue(data.name) ?? null,
		createdUtc: numberValue(data.created_utc) ?? numberValue(data.created),
		commentKarma: numberValue(data.comment_karma),
		linkKarma: numberValue(data.link_karma),
		totalKarma: numberValue(data.total_karma),
		isGold: booleanValue(data.is_gold),
		isMod: booleanValue(data.is_mod),
		verified: booleanValue(data.verified),
		hasVerifiedEmail:
			typeof data.has_verified_email === 'boolean' ? data.has_verified_email : null,
	}
}

export function shapeMe(body: unknown): ShapedUser & { hasMail: boolean | null } {
	const data = asRecord(body) ?? unwrapThingData(body)
	return {
		...shapeUser({ kind: 't2', data }),
		hasMail: typeof data.has_mail === 'boolean' ? data.has_mail : null,
	}
}

function unwrapThingData(body: unknown): JsonRecord {
	const record = asRecord(body)
	if (!record) {
		throw new Error('Reddit response was not an object.')
	}
	const nested = asRecord(record.data)
	return nested ?? record
}

function unwrapListing(body: unknown): JsonRecord {
	const record = asRecord(body)
	if (!record) {
		throw new Error('Reddit listing response was not an object.')
	}
	if (record.kind === 'Listing') {
		return asRecord(record.data) ?? {}
	}
	return asRecord(record.data) ?? record
}

function childrenOf(listing: JsonRecord): Array<JsonRecord & { kind?: string; data?: unknown }> {
	if (!Array.isArray(listing.children)) return []
	return listing.children.filter((child): child is JsonRecord & { kind?: string; data?: unknown } => {
		return Boolean(child && typeof child === 'object' && !Array.isArray(child))
	})
}

function flattenComments(children: Array<JsonRecord & { kind?: string; data?: unknown }>): ShapedComment[] {
	const comments: ShapedComment[] = []
	for (const child of children) {
		if (child.kind === 'more') continue
		if (child.kind === 't1') {
			const comment = shapeComment(child.data)
			if (comment) comments.push(comment)
			const replies = asRecord(child.data)?.replies
			if (replies && typeof replies === 'object') {
				comments.push(...shapeCommentListing(replies))
			}
		}
	}
	return comments
}

function permalinkOf(value: unknown): string | null {
	const permalink = stringValue(value)
	if (!permalink) return null
	if (permalink.startsWith('http')) return permalink
	return 'https://www.reddit.com' + (permalink.startsWith('/') ? permalink : '/' + permalink)
}