export type { Cache } from './cache'; export { MemoryCache, NoopCache } from './cache'; import { type Cache, MemoryCache, NoopCache } from './cache'; 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; /** Inject a custom cache (Redis, Next.js Data Cache, etc.). If provided, cacheTtlMs is ignored for the internal map. */ cache?: Cache; defaultHeaders?: Record; } export interface RequestOptions { /** * Reserved for cache-implementation use; not forwarded to fetch. * Pass to your Cache.set() implementation to control per-request TTL. */ 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 InviteResult { outcome: InviteOutcome; invitation?: MailingListInvitation; detail?: string; } 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; } // ---------- Entity-level analytics (content_search faceting) ---------- // // The analytics endpoints are campaign-scoped only, but /mentions/ accepts // content_search composed with sentiment/platform/date filters and always // returns an exact `count`. We build per-entity views by faceting that search: // one request gives count + a 20-mention sample (sentiment/platform are exact // when count <= 20, which covers most entities at current volume); a few // count-only requests add precise week-over-week momentum. export interface PlatformShare { platform: string; count: number; } export interface EntityBrief { term: string; total: number; exact: boolean; // sample covers the full population (count <= sample size) sentiment: SentimentSplit; platformMix: PlatformShare[]; sample: Mention[]; // up to 20, ordered by engagement } export interface EntityStats extends EntityBrief { recent: Mention[]; // up to 20, newest first recent7d: number; prev7d: number; momentumPct: number | null; // last 7 days vs the prior 7 sparkline: { date: string; count: number }[]; } 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; } } 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'; // Platform enum the /mentions/ `platforms` filter accepts. const PLATFORM_KEYS = ['twitter', 'reddit', 'facebook', 'instagram', 'tiktok', 'linkedin'] as const; 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); } /** Returns YYYY-MM-DD for today ± offsetDays (UTC). */ function isoDay(offsetDays = 0): string { const d = new Date(); d.setUTCDate(d.getUTCDate() + offsetDays); return d.toISOString().slice(0, 10); } function sentimentOf(mentions: Mention[]): SentimentSplit { const s: SentimentSplit = { positive: 0, negative: 0, neutral: 0 }; for (const m of mentions) if (m.sentiment in s) s[m.sentiment] += 1; return s; } function platformMixOf(mentions: Mention[]): PlatformShare[] { const mix = new Map(); for (const m of mentions) mix.set(m.platform, (mix.get(m.platform) ?? 0) + 1); return [...mix.entries()] .map(([platform, count]) => ({ platform, count })) .sort((a, b) => b.count - a.count); } 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 cacheImpl: Cache; private readonly usedCacheKeys = new Set(); 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 ?? {}; this.cacheImpl = options.cache ?? (this.cacheTtlMs > 0 ? new MemoryCache() : new NoopCache()); } async clearCache(): Promise { const keys = [...this.usedCacheKeys]; this.usedCacheKeys.clear(); await Promise.all(keys.map((k) => this.cacheImpl.delete(k))); } async get(path: string, params: QueryParams = {}, options: RequestOptions = {}): Promise { const query = encodeParams(params); const url = `${joinUrl(this.baseUrl, path)}${query ? `?${query}` : ''}`; const cached = await this.cacheImpl.get(url); if (cached !== undefined) return cached as T; const value = await this.request('GET', path, url, undefined, options); const ttlMs = options.revalidateSeconds !== undefined ? options.revalidateSeconds * 1000 : this.cacheTtlMs; await this.cacheImpl.set(url, value, ttlMs); this.usedCacheKeys.add(url); 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 { 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}` }; } // ---------- Entity analytics ---------- /** One request: count + a top-engagement sample, with sentiment/platform derived. */ async getEntityBrief(term: string, campaignId?: string, options: RequestOptions = {}): Promise { const d = await this.getMentions( { campaign_ids: campaignId, content_search: term, ordering: '-engagement_count' }, options, ); return { term, total: d.count, exact: d.count <= d.results.length, sentiment: sentimentOf(d.results), platformMix: platformMixOf(d.results), sample: d.results, }; } /** * Full single-entity dashboard: brief + exact distributions + newest mentions + daily timeline. * All sub-requests are best-effort — a single failure never zeros out the entity. */ async getEntityStats(term: string, campaignId?: string, options: RequestOptions = {}): Promise { const base = { campaign_ids: campaignId, content_search: term }; const brief = await this.getEntityBrief(term, campaignId, options); const [recentPage, sparkline, exactSentiment, exactPlatformMix] = await Promise.all([ this.getMentions({ ...base, ordering: '-published_at' }, options).catch(() => null), this.getEntityTimeline(term, campaignId, brief.total, undefined, options).catch( () => [] as { date: string; count: number }[], ), this.getExactSentiment(term, campaignId, brief.total, options).catch(() => null), this.getExactPlatformMix(term, campaignId, brief.total, options).catch(() => null), ]); const recent = recentPage?.results ?? []; const sumOf = (pts: { count: number }[]) => pts.reduce((s, p) => s + p.count, 0); const recent7d = sumOf(sparkline.slice(-7)); const prev7d = sumOf(sparkline.slice(-14, -7)); const momentumPct = sparkline.length === 0 ? null : prev7d > 0 ? ((recent7d - prev7d) / prev7d) * 100 : recent7d > 0 ? 100 : null; const distributionsExact = exactSentiment !== null && exactPlatformMix !== null; return { ...brief, exact: distributionsExact ? true : brief.exact, sentiment: exactSentiment ?? brief.sentiment, platformMix: exactPlatformMix ?? brief.platformMix, recent, recent7d, prev7d, momentumPct, sparkline, }; } /** Fetch briefs for many entities with bounded concurrency (rate-limit friendly). */ async getEntityBriefs( terms: string[], campaignId?: string, concurrency = 20, options: RequestOptions = {}, ): Promise> { const out = new Map(); let cursor = 0; const worker = async () => { while (cursor < terms.length) { const term = terms[cursor++]; try { out.set(term, await this.getEntityBrief(term, campaignId, options)); } catch { // Silent: the entity simply doesn't appear in the map. } } }; await Promise.all(Array.from({ length: Math.min(concurrency, terms.length) }, worker)); return out; } /** Resolve a live campaign id by matching a substring of its name. */ async getCampaignIdByMatch(match: string, options: RequestOptions = {}): Promise { const campaigns = await this.getCampaigns(options).catch(() => [] as Campaign[]); const needle = match.toLowerCase(); return campaigns.find((c) => c.name.toLowerCase().includes(needle))?.id; } /** * Exact sentiment split for a term, by faceting /mentions/ count over each * sentiment value. Returns null if any facet fails or totals don't reconcile * with `total` (API silently ignored the filter). */ private async getExactSentiment( term: string, campaignId: string | undefined, total: number, options: RequestOptions = {}, ): Promise { const order: Sentiment[] = ['positive', 'negative', 'neutral']; const counts = await Promise.all( order.map((s) => this.getMentions({ campaign_ids: campaignId, content_search: term, sentiments: s }, options) .then((d) => d.count) .catch(() => null), ), ); if (counts.some((c) => c === null)) return null; const [positive, negative, neutral] = counts as number[]; // Every mention carries exactly one sentiment; facets must sum to the known total. // A mismatch means the filter wasn't honored — distrust it. if (positive + negative + neutral !== total) return null; return { positive, negative, neutral }; } /** * Exact platform mix for a term, by faceting /mentions/ count over each platform. * Returns null if any facet fails or the facets sum to more than the known total * (the `platforms` filter was ignored). A sum below total is fine — mentions may * sit on platforms outside PLATFORM_KEYS. */ private async getExactPlatformMix( term: string, campaignId: string | undefined, total: number, options: RequestOptions = {}, ): Promise { const counts = await Promise.all( PLATFORM_KEYS.map((platform) => this.getMentions({ campaign_ids: campaignId, content_search: term, platforms: platform }, options) .then((d) => ({ platform, count: d.count }) as PlatformShare) .catch(() => null), ), ); if (counts.some((c) => c === null)) return null; const mix = counts as PlatformShare[]; if (mix.reduce((sum, p) => sum + p.count, 0) > total) return null; return mix.filter((p) => p.count > 0).sort((a, b) => b.count - a.count); } /** * Real daily mention volume for the last `days` days. * * Uses CUMULATIVE DIFFERENCING (date_from only) rather than [date_from, date_to] * windows. The API counts date_to inclusively, so adjacent windows share a day * and double-count interior days. Differences of a monotonic cumulative series * are non-negative and sum to at most `total`. * * If the earliest cumulative exceeds `total`, content_search was dropped on * date-filtered queries — return [] rather than wrong bars. */ private async getEntityTimeline( term: string, campaignId: string | undefined, total: number, days = 14, options: RequestOptions = {}, ): Promise<{ date: string; count: number }[]> { // One boundary per day plus tomorrow (+1) so today differences out cleanly. const offsets = Array.from({ length: days + 1 }, (_, i) => -(days - 1 - i)); // oldest .. +1 const cumulative = new Array(offsets.length).fill(null); let cursor = 0; const worker = async () => { while (cursor < offsets.length) { const idx = cursor++; const d = await this.getMentions( { campaign_ids: campaignId, content_search: term, date_from: isoDay(offsets[idx]) }, options, ).catch(() => null); if (d) cumulative[idx] = d.count; } }; // Bounded fan-out: the retry/backoff absorbs the rest of the burst. await Promise.all(Array.from({ length: Math.min(6, offsets.length) }, worker)); const earliest = cumulative[0]; if (earliest != null && earliest > total) return []; const points: { date: string; count: number }[] = []; for (let i = 0; i < days; i++) { const hi = cumulative[i]; // on/after day i const lo = cumulative[i + 1]; // on/after the next day if (hi == null || lo == null) continue; points.push({ date: isoDay(offsets[i]), count: Math.max(0, hi - lo) }); } return points; } 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: RequestInit = { 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 === '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); }