export type Sentiment = 'positive' | 'negative' | 'neutral'; export type SentimentSplit = { positive: number; negative: number; neutral: number }; export type QueryValue = string | number | boolean | null | undefined; export type QueryParams = Record; export interface SocialhoseClientOptions { apiKey: string; baseUrl?: string; userAgent?: string; fetch?: typeof fetch; timeoutMs?: number; retries?: number; retryDelayMs?: (attempt: number) => number; cacheTtlMs?: number; defaultHeaders?: Record; } export interface RequestOptions { revalidateSeconds?: number; signal?: AbortSignal; headers?: Record; } export interface Campaign { id: string; name: string; description: string; status: string; campaign_type: string; platforms: string[]; tags: string[]; } export interface Overview { total_mentions: number; total_authors: number; estimated_reach: number; sentiment_distribution: SentimentSplit; platform_breakdown: Record; engagement: { total: number; average: number; max: number; likes: number; shares: number; comments: number; views: number; }; growth: { mentions_pct: number; engagement_pct: number; positive_sentiment_pct_delta: number; }; } export interface TimelinePoint { date: string; count: number; sentiment: SentimentSplit; engagement: number; } export interface ShareOfVoiceItem { campaign_id: string; name: string; count: number; share_pct: number; engagement: number; sentiment: SentimentSplit; } export interface PlatformStat { platform: string; count: number; engagement: number; } export interface KeywordStat { keyword: string; count: number; sentiment: SentimentSplit; } export interface TrendingItem { keyword: string; count: number; previous_count: number; change_pct: number; } export interface TopMention { id: string; platform: string; author: string; reach: number; engagement: number; likes: number; shares: number; comments: number; sentiment: Sentiment; url: string; content_preview: string; } export interface Mention { id: string; platform: string; campaign_id: string; campaign_name: string; classification: string; content: string; title: string | null; url: string; sentiment: Sentiment; sentiment_score: number; engagement_count: number; likes: number; shares: number; comments: number; views_count: number; has_media: boolean; hashtags: string[]; keywords_matched: string[]; language: string | null; country: string | null; published_at: string; author: { name: string | null; handle: string | null; avatar_url: string | null; url: string | null; followers: number; verified: boolean; }; } export interface MailingList { id: string; campaign_id: string; name: string; description: string; is_active: boolean; alert_frequency: string; email_template: string; } export interface MailingListInvitation { id: string; email: string; first_name: string; last_name: string; role: string; status: 'pending' | 'accepted' | 'declined' | 'expired' | 'cancelled'; sent_at: string; expires_at: string; accepted_at: string | null; } export type InviteOutcome = 'invited' | 'already' | 'error'; export interface Paginated { count: number; next: string | null; previous: string | null; results: T[]; } export interface AnalyticsFilters { campaign_ids?: string; date_from?: string; date_to?: string; platforms?: string; sentiments?: string; } export interface MentionFilters extends AnalyticsFilters { page?: number; content_search?: string; ordering?: string; } export class SocialhoseError extends Error { readonly status?: number; readonly path: string; readonly body?: string; readonly cause?: unknown; constructor(message: string, args: { status?: number; path: string; body?: string; cause?: unknown }) { super(message); this.name = 'SocialhoseError'; this.status = args.status; this.path = args.path; this.body = args.body; this.cause = args.cause; } } type CacheEntry = { at: number; value: unknown }; type FetchInitWithNext = RequestInit & { next?: { revalidate?: number } }; const DEFAULT_BASE_URL = 'https://socialhose.net/api/public/v1'; const DEFAULT_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'; function sleep(ms: number): Promise { if (ms <= 0) return Promise.resolve(); return new Promise((resolve) => setTimeout(resolve, ms)); } function num(v: unknown): number { if (typeof v === 'number') return v; if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) return Number(v); return 0; } function joinUrl(baseUrl: string, path: string): string { return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; } function encodeParams(params: QueryParams): string { const qs = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== '') qs.set(key, String(value)); } return qs.toString(); } function timeoutSignal(timeoutMs: number, upstream?: AbortSignal): AbortSignal { if (upstream) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(new DOMException('Timeout', 'TimeoutError')), timeoutMs); const abort = () => controller.abort(upstream.reason); upstream.addEventListener('abort', abort, { once: true }); controller.signal.addEventListener( 'abort', () => { clearTimeout(timeout); upstream.removeEventListener('abort', abort); }, { once: true }, ); return controller.signal; } return AbortSignal.timeout(timeoutMs); } export class SocialhoseClient { readonly apiKey: string; readonly baseUrl: string; readonly userAgent: string; readonly timeoutMs: number; readonly retries: number; readonly cacheTtlMs: number; private readonly fetchImpl: typeof fetch; private readonly retryDelayMs: (attempt: number) => number; private readonly defaultHeaders: Record; private readonly cache = new Map(); constructor(options: SocialhoseClientOptions) { if (!options.fetch && typeof fetch === 'undefined') throw new Error('A fetch implementation is required'); this.apiKey = options.apiKey; this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; this.userAgent = options.userAgent ?? DEFAULT_UA; this.fetchImpl = options.fetch ?? fetch; this.timeoutMs = options.timeoutMs ?? 8_000; this.retries = options.retries ?? 3; this.retryDelayMs = options.retryDelayMs ?? ((attempt) => 400 * 2 ** attempt + Math.random() * 200); this.cacheTtlMs = options.cacheTtlMs ?? 60_000; this.defaultHeaders = options.defaultHeaders ?? {}; } clearCache(): void { this.cache.clear(); } async get(path: string, params: QueryParams = {}, options: RequestOptions = {}): Promise { const query = encodeParams(params); const url = `${joinUrl(this.baseUrl, path)}${query ? `?${query}` : ''}`; const hit = this.cache.get(url); if (this.cacheTtlMs > 0 && hit && Date.now() - hit.at < this.cacheTtlMs) return hit.value as T; const value = await this.request('GET', path, url, undefined, options); if (this.cacheTtlMs > 0) this.cache.set(url, { at: Date.now(), value }); return value; } async post(path: string, body: unknown, options: RequestOptions = {}): Promise<{ status: number; data: T | null }> { const url = joinUrl(this.baseUrl, path); return this.requestWithStatus('POST', path, url, body, options); } async getCampaigns(options: RequestOptions = {}): Promise { const d = await this.get>('/campaigns/', { page: 1 }, options); return d.results; } getCampaign(id: string, options: RequestOptions = {}): Promise { return this.get(`/campaigns/${id}/`, {}, options); } async getOverview(filters: AnalyticsFilters = {}, options: RequestOptions = {}): Promise { const d = await this.get('/analytics/overview/', filters as QueryParams, options); return { ...d, total_mentions: num(d.total_mentions), total_authors: num(d.total_authors) }; } async getTimeline( filters: AnalyticsFilters & { interval?: 'day' | 'week' | 'month' } = {}, options: RequestOptions = {}, ): Promise { const d = await this.get<{ series?: TimelinePoint[] }>( '/analytics/timeline/', { interval: 'day', ...filters }, options, ); return d.series ?? []; } getSentiment( filters: AnalyticsFilters = {}, options: RequestOptions = {}, ): Promise<{ distribution: SentimentSplit; by_platform: Record }> { return this.get('/analytics/sentiment/', filters as QueryParams, options); } getShareOfVoice( filters: AnalyticsFilters = {}, options: RequestOptions = {}, ): Promise<{ total_mentions: number; campaigns: ShareOfVoiceItem[] }> { return this.get('/analytics/share-of-voice/', filters as QueryParams, options); } async getPlatforms(filters: AnalyticsFilters = {}, options: RequestOptions = {}): Promise { const d = await this.get<{ platforms?: PlatformStat[] }>('/analytics/platforms/', filters as QueryParams, options); return d.platforms ?? []; } async getTopKeywords( filters: AnalyticsFilters & { limit?: number } = {}, options: RequestOptions = {}, ): Promise { const d = await this.get<{ keywords?: KeywordStat[] }>( '/analytics/top-keywords/', { limit: 12, ...filters }, options, ); return d.keywords ?? []; } async getTrending( filters: AnalyticsFilters & { limit?: number } = {}, options: RequestOptions = {}, ): Promise { const d = await this.get<{ trending?: TrendingItem[] }>( '/analytics/trending/', { limit: 8, ...filters }, options, ); return d.trending ?? []; } async getTopMentions( filters: AnalyticsFilters & { limit?: number } = {}, options: RequestOptions = {}, ): Promise { const d = await this.get<{ mentions?: TopMention[] }>( '/analytics/top-mentions/', { limit: 6, ...filters }, options, ); return d.mentions ?? []; } getMentions( filters: MentionFilters = {}, optionsOrRevalidate: RequestOptions | number = {}, ): Promise> { const options = typeof optionsOrRevalidate === 'number' ? { revalidateSeconds: optionsOrRevalidate } : optionsOrRevalidate; return this.get>('/mentions/', { page: 1, ...filters }, options); } async getMailingLists(options: RequestOptions = {}): Promise { const d = await this.get>('/mailing-lists/', { page: 1 }, options); return d.results; } async inviteMailingListMember( listId: string, invite: { email: string; first_name?: string; last_name?: string; invitation_message?: string }, options: RequestOptions = {}, ): Promise<{ outcome: InviteOutcome; invitation?: MailingListInvitation; detail?: string }> { const { status, data } = await this.post<{ status?: string; invitation?: MailingListInvitation; detail?: string; }>(`/mailing-lists/${listId}/members/`, invite, options); if (status === 201 && data?.status === 'invited') return { outcome: 'invited', invitation: data.invitation }; if (status === 409) return { outcome: 'already', detail: data?.detail }; return { outcome: 'error', detail: data?.detail ?? `Socialhose returned HTTP ${status}` }; } private async request( method: 'GET' | 'POST', path: string, url: string, body: unknown, options: RequestOptions, ): Promise { const { data } = await this.requestWithStatus(method, path, url, body, options); return data as T; } private async requestWithStatus( method: 'GET' | 'POST', path: string, url: string, body: unknown, options: RequestOptions, ): Promise<{ status: number; data: T | null }> { if (!this.apiKey) throw new Error('Socialhose apiKey is required'); let res: Response | null = null; let lastErr: unknown = null; for (let attempt = 0; attempt <= this.retries; attempt++) { try { const init: FetchInitWithNext = { method, headers: { ...this.defaultHeaders, ...options.headers, Authorization: `Api-Key ${this.apiKey}`, Accept: 'application/json', 'User-Agent': this.userAgent, ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}), }, body: method === 'POST' ? JSON.stringify(body) : undefined, signal: timeoutSignal(this.timeoutMs, options.signal), }; if (method === 'GET' && options.revalidateSeconds !== undefined) { init.next = { revalidate: options.revalidateSeconds }; } if (method === 'POST') init.cache = 'no-store'; res = await this.fetchImpl(url, init); } catch (err) { lastErr = err; res = null; if (attempt === this.retries) break; await sleep(this.retryDelayMs(attempt)); continue; } if (res.status !== 429 && res.status < 500) break; if (attempt === this.retries) break; await sleep(this.retryDelayMs(attempt)); } if (!res) { throw new SocialhoseError(`Socialhose request failed on ${path}: ${String(lastErr)}`, { path, cause: lastErr, }); } const text = await res.text().catch(() => ''); const data = text ? (JSON.parse(text) as T) : null; if (!res.ok && !(method === 'POST' && res.status === 409)) { throw new SocialhoseError(`Socialhose ${res.status} on ${path}: ${text.slice(0, 200)}`, { status: res.status, path, body: text, }); } return { status: res.status, data }; } } export function createSocialhoseClient(options: SocialhoseClientOptions): SocialhoseClient { return new SocialhoseClient(options); }