feat: add JavaScript Socialhose API SDK

This commit is contained in:
Mo Elzubeir
2026-05-29 12:46:42 -05:00
commit e34552ac33
12 changed files with 2560 additions and 0 deletions
+485
View File
@@ -0,0 +1,485 @@
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<string, QueryValue>;
export interface SocialhoseClientOptions {
apiKey: string;
baseUrl?: string;
userAgent?: string;
fetch?: typeof fetch;
timeoutMs?: number;
retries?: number;
retryDelayMs?: (attempt: number) => number;
cacheTtlMs?: number;
defaultHeaders?: Record<string, string>;
}
export interface RequestOptions {
revalidateSeconds?: number;
signal?: AbortSignal;
headers?: Record<string, string>;
}
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<string, number>;
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<T> {
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<void> {
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<string, string>;
private readonly cache = new Map<string, CacheEntry>();
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<T>(path: string, params: QueryParams = {}, options: RequestOptions = {}): Promise<T> {
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<T>('GET', path, url, undefined, options);
if (this.cacheTtlMs > 0) this.cache.set(url, { at: Date.now(), value });
return value;
}
async post<T>(path: string, body: unknown, options: RequestOptions = {}): Promise<{ status: number; data: T | null }> {
const url = joinUrl(this.baseUrl, path);
return this.requestWithStatus<T>('POST', path, url, body, options);
}
async getCampaigns(options: RequestOptions = {}): Promise<Campaign[]> {
const d = await this.get<Paginated<Campaign>>('/campaigns/', { page: 1 }, options);
return d.results;
}
getCampaign(id: string, options: RequestOptions = {}): Promise<Campaign> {
return this.get<Campaign>(`/campaigns/${id}/`, {}, options);
}
async getOverview(filters: AnalyticsFilters = {}, options: RequestOptions = {}): Promise<Overview> {
const d = await this.get<Overview>('/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<TimelinePoint[]> {
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<string, SentimentSplit> }> {
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<PlatformStat[]> {
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<KeywordStat[]> {
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<TrendingItem[]> {
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<TopMention[]> {
const d = await this.get<{ mentions?: TopMention[] }>(
'/analytics/top-mentions/',
{ limit: 6, ...filters },
options,
);
return d.mentions ?? [];
}
getMentions(
filters: MentionFilters = {},
optionsOrRevalidate: RequestOptions | number = {},
): Promise<Paginated<Mention>> {
const options = typeof optionsOrRevalidate === 'number' ? { revalidateSeconds: optionsOrRevalidate } : optionsOrRevalidate;
return this.get<Paginated<Mention>>('/mentions/', { page: 1, ...filters }, options);
}
async getMailingLists(options: RequestOptions = {}): Promise<MailingList[]> {
const d = await this.get<Paginated<MailingList>>('/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<T>(
method: 'GET' | 'POST',
path: string,
url: string,
body: unknown,
options: RequestOptions,
): Promise<T> {
const { data } = await this.requestWithStatus<T>(method, path, url, body, options);
return data as T;
}
private async requestWithStatus<T>(
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);
}