252ea713b1
- Add Cache interface with MemoryCache and NoopCache implementations - Make SocialhoseClient accept injectable cache option - Remove Next.js next.revalidate coupling from transport - Add entity analytics: getEntityBrief, getEntityStats, getEntityBriefs, getCampaignIdByMatch with exact sentiment/platform faceting and cumulative-differenced timeline - Add 23 new tests (29 total) covering entity analytics, cache injection, sentiment reconciliation, bounded concurrency, and timeline differencing - Update README with entity analytics, custom caching, and pitfalls sections - Fix CI branch: main -> master
751 lines
24 KiB
TypeScript
751 lines
24 KiB
TypeScript
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<string, QueryValue>;
|
|
|
|
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<string, string>;
|
|
}
|
|
|
|
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<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 InviteResult {
|
|
outcome: InviteOutcome;
|
|
invitation?: MailingListInvitation;
|
|
detail?: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ---------- 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<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);
|
|
}
|
|
|
|
/** 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<string, number>();
|
|
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<string, string>;
|
|
private readonly cacheImpl: Cache;
|
|
private readonly usedCacheKeys = new Set<string>();
|
|
|
|
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<void> {
|
|
const keys = [...this.usedCacheKeys];
|
|
this.usedCacheKeys.clear();
|
|
await Promise.all(keys.map((k) => this.cacheImpl.delete(k)));
|
|
}
|
|
|
|
async get<T>(path: string, params: QueryParams = {}, options: RequestOptions = {}): Promise<T> {
|
|
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<T>('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<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<InviteResult> {
|
|
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<EntityBrief> {
|
|
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<EntityStats> {
|
|
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<Map<string, EntityBrief>> {
|
|
const out = new Map<string, EntityBrief>();
|
|
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<string | undefined> {
|
|
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<SentimentSplit | null> {
|
|
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<PlatformShare[] | null> {
|
|
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<number | null>(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<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: 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);
|
|
}
|