diff --git a/README.md b/README.md index 808fed2..c56da7f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,15 @@ const mentions = await socialhose.getMentions({ console.log(mentions.count); ``` +Documentation lives with the package: + +- [Quick README](./sdks/javascript/README.md) +- [Usage guide](./sdks/javascript/docs/GUIDE.md) +- [API reference](./sdks/javascript/docs/API.md) +- [Entity analytics](./sdks/javascript/docs/ENTITY_ANALYTICS.md) +- [Caching and retries](./sdks/javascript/docs/CACHING_AND_RETRIES.md) +- [Examples](./sdks/javascript/examples) + ## Development Use pnpm 9.x. diff --git a/sdks/javascript/README.md b/sdks/javascript/README.md index 5efdea5..cbb151c 100644 --- a/sdks/javascript/README.md +++ b/sdks/javascript/README.md @@ -2,13 +2,15 @@ TypeScript SDK for the Socialhose Public API. +Use it from backend TypeScript/JavaScript to fetch campaigns, analytics, mentions, mailing lists, and SDK-composed entity analytics with authentication, retries, timeouts, and caching handled consistently. + ## Install ```bash npm install @socialhose/api ``` -Node 18+ is required because the SDK uses the built-in `fetch`, `Response`, and `AbortSignal.timeout` APIs. You can pass a custom `fetch` implementation if needed. +Node 18+ is required because the SDK uses built-in `fetch`, `Response`, and `AbortSignal.timeout`. You can pass a custom `fetch` implementation if needed. ## Quickstart @@ -28,6 +30,14 @@ const mentions = await socialhose.getMentions({ console.log(mentions.count, mentions.results[0]?.content); ``` +## Documentation + +- [Usage guide](./docs/GUIDE.md) — what the SDK is, setup, common workflows, operational guidance. +- [API reference](./docs/API.md) — every public class, function, method, option, and exported type. +- [Entity analytics](./docs/ENTITY_ANALYTICS.md) — how term-level analytics are built, accuracy safeguards, rate-limit guidance. +- [Caching and retries](./docs/CACHING_AND_RETRIES.md) — cache contract, retry policy, failure semantics. +- [Examples](./examples) — runnable TypeScript examples. + ## Configuration ```ts @@ -42,127 +52,29 @@ const socialhose = new SocialhoseClient({ The SDK sends `Authorization: Api-Key ` and a browser-like `User-Agent` by default. The user-agent is intentional: the current Socialhose API edge rejects some non-browser requests. -## Endpoints +## Main capabilities -### Campaigns -- `getCampaigns()` -- `getCampaign(id)` +- Campaign discovery: `getCampaigns()`, `getCampaign(id)`, `getCampaignIdByMatch(match)`. +- Analytics: `getOverview()`, `getTimeline()`, `getSentiment()`, `getShareOfVoice()`, `getPlatforms()`, `getTopKeywords()`, `getTrending()`, `getTopMentions()`. +- Mentions: `getMentions()` with campaign, date, platform, sentiment, text search, pagination, and ordering filters. +- Mailing lists: `getMailingLists()`, `inviteMailingListMember()`. +- Entity analytics: `getEntityBrief()`, `getEntityStats()`, `getEntityBriefs()`. +- Low-level access: `get(path, params)`, `post(path, body)`. +- Caching: built-in `MemoryCache`, disabling via `NoopCache`, or any custom `Cache` implementation. -### Analytics -- `getOverview(filters)` -- `getTimeline(filters)` -- `getSentiment(filters)` -- `getShareOfVoice(filters)` -- `getPlatforms(filters)` -- `getTopKeywords(filters)` -- `getTrending(filters)` -- `getTopMentions(filters)` +## Entity analytics warning -### Mentions -- `getMentions(filters)` - -### Mailing Lists -- `getMailingLists()` -- `inviteMailingListMember(listId, invite)` - -### Entity Analytics -- `getEntityBrief(term, campaignId?)` — one request: count + top-20 engagement sample with derived sentiment/platform -- `getEntityStats(term, campaignId?)` — full dashboard: exact sentiment faceting, exact platform mix, 14-day cumulative-differenced timeline, 7d momentum -- `getEntityBriefs(terms, campaignId?, concurrency?)` — batch entity resolution with bounded concurrency (default 20) -- `getCampaignIdByMatch(substring)` — resolve a live campaign ID by matching its name - -### Low-Level -- `get(path, params)` for direct GET access -- `post(path, body)` for direct POST access - -## Filtering examples +Entity analytics fan out multiple `/mentions/` requests per term. Use `cacheTtlMs`, `revalidateSeconds`, or a persistent shared cache to stay under the approximate 60 req/min API-key rate limit. ```ts -await socialhose.getOverview({ - campaign_ids: 'campaign-id', - date_from: '2026-05-01', - date_to: '2026-05-29', - platforms: 'twitter,reddit', - sentiments: 'negative', -}); - -await socialhose.getTimeline({ - campaign_ids: 'campaign-id', - interval: 'day', +const stats = await socialhose.getEntityStats('RSF', 'campaign-id', { + revalidateSeconds: 900, }); ``` -## Custom Caching - -The SDK ships with `MemoryCache` (in-memory, per-entry TTL) and `NoopCache` (no caching). You can inject your own by implementing the `Cache` interface: - -```ts -import { SocialhoseClient, Cache } from '@socialhose/api'; - -class RedisCache implements Cache { - async get(key: string) { /* redis.get(key) */ } - async set(key: string, value: unknown, ttlMs: number) { /* redis.set(key, value, 'PX', ttlMs) */ } - async delete(key: string) { /* redis.del(key) */ } -} - -const socialhose = new SocialhoseClient({ - apiKey: process.env.SOCIALHOSE_API_KEY!, - cache: new RedisCache(), -}); -``` - -Pass `revalidateSeconds` per request to control per-call TTL in your cache implementation: - -```ts -await socialhose.getMentions( - { content_search: 'ozempic' }, - { revalidateSeconds: 3600 }, -); -``` - -## Entity Analytics - -Search across all mentions for a specific term, person, or organization: - -```ts -// Quick count + top mentions -const brief = await socialhose.getEntityBrief('Burhan'); -console.log(brief.total, brief.sentiment, brief.platformMix); - -// Full dashboard: exact distributions, timeline, momentum -const stats = await socialhose.getEntityStats('RSF', 'campaign-id'); -console.log( - stats.total, - stats.momentumPct, // last 7 days vs prior 7 - stats.sentiment, // exact (facets reconcile) or estimated (from sample) - stats.sparkline, // 14-day daily volume -); - -// Batch resolve many entities with bounded concurrency -const briefs = await socialhose.getEntityBriefs( - ['Burhan', 'Hemedti', 'SAF', 'RSF'], - 'campaign-id', - 10, // concurrency -); -``` - -Entity analytics fan out multiple requests per entity (sentiment faceting: 3 calls; platform mix: 6 calls; timeline: 15 calls). Set `cacheTtlMs` or inject a persistent cache to stay under the ~60 req/min rate limit. - -## Pitfalls - -**Cloudflare UA blocking.** The Socialhose API sits behind Cloudflare, which rejects some non-browser User-Agent headers. The SDK defaults to a Chrome 124 UA — don't change it unless you've verified the new UA works. - -**Entity timeline uses cumulative differencing.** The analytics timeline endpoint is campaign-scoped and ignores `content_search`. The SDK facets `/mentions/` by day using cumulative `date_from`-only queries and subtracts consecutive counts. This avoids the API's `date_to` inclusivity bug: overlapping `[date_from, date_to]` windows share a day and double-count it. Don't "simplify" this to day windows. - -**Sentiment reconciliation checks.** The `getEntityStats` exact sentiment and platform distributions validate that facet counts reconcile with the known total (e.g., positive + negative + neutral === total). If they don't match, the API silently dropped the `content_search` filter — the SDK falls back to estimates from the brief's sample rather than showing wrong data. - -**Rate limit ~60 req/min per API key.** Entity analytics fan out many parallel requests. Use the `cache` option with a persistent store (Redis, Next.js Data Cache) to keep warm loads under the limit. - -**Never expose the API key to the browser.** This SDK is designed for server-side use. Always set `SOCIALHOSE_API_KEY` as a server-side environment variable. - ## Errors -Failed requests throw `SocialhoseError` with `status`, `path`, and response `body` when available. +Failed requests throw `SocialhoseError` with `status`, `path`, `body`, and `cause` when available. ```ts import { SocialhoseError } from '@socialhose/api'; @@ -176,12 +88,17 @@ try { } ``` -## Publishing +## Development ```bash +pnpm install pnpm test pnpm typecheck pnpm build -npm publish --access public --provenance ``` +Publishing: + +```bash +npm publish --access public --provenance +``` diff --git a/sdks/javascript/docs/API.md b/sdks/javascript/docs/API.md new file mode 100644 index 0000000..246e8a5 --- /dev/null +++ b/sdks/javascript/docs/API.md @@ -0,0 +1,366 @@ +# API Reference + +Public API reference for `@socialhose/api`, the TypeScript SDK for the Socialhose Public API. + +The SDK is intended for server-side JavaScript/TypeScript. It authenticates requests with an API key, wraps Socialhose REST endpoints, normalizes common response shapes, retries transient failures, applies request timeouts, and caches GET responses. + +## Package exports + +```ts +import { + SocialhoseClient, + createSocialhoseClient, + SocialhoseError, + MemoryCache, + NoopCache, + type Cache, + type SocialhoseClientOptions, + type RequestOptions, + type AnalyticsFilters, + type MentionFilters, +} from '@socialhose/api'; +``` + +## Authentication + +Every request sends: + +- `Authorization: Api-Key ` +- `Accept: application/json` +- a browser-like `User-Agent` by default +- `Content-Type: application/json` for `POST` + +Do not use this SDK directly in browser code. Keep `SOCIALHOSE_API_KEY` on the server. + +## `SocialhoseClientOptions` + +Configuration object for `new SocialhoseClient(options)` and `createSocialhoseClient(options)`. + +- `apiKey: string` — Socialhose API key. Required; empty strings fail on first request. +- `baseUrl?: string` — API root. Default: `https://socialhose.net/api/public/v1`. +- `userAgent?: string` — request user-agent. Default is Chrome-like because the API edge may reject generic Node clients. +- `fetch?: typeof fetch` — custom fetch implementation for tests, instrumentation, or runtimes without global fetch. +- `timeoutMs?: number` — per-attempt timeout. Default: `8000`. +- `retries?: number` — retry count after the first attempt. Default: `3`. +- `retryDelayMs?: (attempt: number) => number` — retry backoff function. Default: exponential backoff with jitter. +- `cacheTtlMs?: number` — default GET cache TTL in milliseconds. Default: `60000`; set `0` to disable the built-in memory cache. +- `cache?: Cache` — custom cache implementation. If supplied, it replaces the internal cache. +- `defaultHeaders?: Record` — extra headers sent with every request. + +## `RequestOptions` + +Per-request options. + +- `revalidateSeconds?: number` — override cache TTL for this call, in seconds. Passed only to `Cache.set`; not sent to the API. +- `signal?: AbortSignal` — caller abort signal combined with the SDK timeout. +- `headers?: Record` — request-specific headers. + +## `new SocialhoseClient(options)` + +Creates an authenticated API client. + +```ts +const socialhose = new SocialhoseClient({ + apiKey: process.env.SOCIALHOSE_API_KEY!, + timeoutMs: 8_000, + retries: 3, + cacheTtlMs: 60_000, +}); +``` + +## `createSocialhoseClient(options)` + +Factory wrapper around the constructor. + +```ts +const socialhose = createSocialhoseClient({ apiKey }); +``` + +Returns `SocialhoseClient`. + +## Low-level HTTP methods + +### `client.get(path, params?, options?)` + +Performs an authenticated cached GET request. + +- `path: string` — endpoint path, with or without leading slash. +- `params?: QueryParams` — query parameters. `undefined`, `null`, and `''` values are omitted. +- `options?: RequestOptions` — cache TTL, abort signal, and extra headers. + +Returns `Promise`. + +Behavior: + +- Builds the URL from `baseUrl`, `path`, and `params`. +- Uses the full URL as the cache key. +- Retries network errors, timeouts, `429`, and `5xx` responses. +- Throws `SocialhoseError` for non-OK responses after retries. + +### `client.post(path, body, options?)` + +Performs an authenticated JSON POST request. + +- `path: string` — endpoint path. +- `body: unknown` — JSON-serialized request body. +- `options?: RequestOptions` — abort signal and extra headers. + +Returns `Promise<{ status: number; data: T | null }>`. + +Behavior: + +- Never uses the GET cache. +- Sends `Content-Type: application/json`. +- Returns the HTTP status so higher-level methods can normalize status-specific outcomes. +- Lets POST `409` responses through for conflict normalization. + +## Campaign methods + +### `client.getCampaigns(options?)` + +Fetches first-page campaigns. + +Endpoint: `GET /campaigns/?page=1` + +Returns `Promise`. + +Use it to list accessible campaigns or populate campaign selectors. The helper reads page 1 only; use `get>()` for manual pagination. + +### `client.getCampaign(id, options?)` + +Fetches one campaign by ID. + +Endpoint: `GET /campaigns/{id}/` + +Returns `Promise`. + +Throws `SocialhoseError` when the campaign is missing or inaccessible. + +### `client.getCampaignIdByMatch(match, options?)` + +Returns the first campaign ID whose name contains `match`, case-insensitively. + +Returns `Promise`. + +If campaign fetching fails or no campaign matches, returns `undefined`. Use exact IDs when names may collide. + +## Analytics filters + +`AnalyticsFilters` are passed through as query params: + +- `campaign_ids?: string` — one ID or comma-separated IDs. +- `date_from?: string` — lower date bound, commonly `YYYY-MM-DD`. +- `date_to?: string` — upper date bound, commonly `YYYY-MM-DD`. +- `platforms?: string` — platform or comma-separated platforms. +- `sentiments?: string` — sentiment or comma-separated sentiments. + +## Analytics methods + +### `client.getOverview(filters?, options?)` + +Endpoint: `GET /analytics/overview/` + +Returns `Promise` with total mentions, authors, estimated reach, sentiment distribution, platform breakdown, engagement, and growth metrics. + +The SDK coerces `total_mentions` and `total_authors` to numbers because some API responses may return numeric strings. + +### `client.getTimeline(filters?, options?)` + +Endpoint: `GET /analytics/timeline/` + +Filters include `interval?: 'day' | 'week' | 'month'`, defaulting to `day`. + +Returns `Promise`. If the API response lacks `series`, returns `[]`. + +### `client.getSentiment(filters?, options?)` + +Endpoint: `GET /analytics/sentiment/` + +Returns `Promise<{ distribution: SentimentSplit; by_platform: Record }>`. + +### `client.getShareOfVoice(filters?, options?)` + +Endpoint: `GET /analytics/share-of-voice/` + +Returns `Promise<{ total_mentions: number; campaigns: ShareOfVoiceItem[] }>`. + +Each campaign row includes mention count, share percentage, engagement, and sentiment. + +### `client.getPlatforms(filters?, options?)` + +Endpoint: `GET /analytics/platforms/` + +Returns `Promise`. If the API response lacks `platforms`, returns `[]`. + +### `client.getTopKeywords(filters?, options?)` + +Endpoint: `GET /analytics/top-keywords/` + +Filters include `limit?: number`, defaulting to `12`. + +Returns `Promise`. If the API response lacks `keywords`, returns `[]`. + +### `client.getTrending(filters?, options?)` + +Endpoint: `GET /analytics/trending/` + +Filters include `limit?: number`, defaulting to `8`. + +Returns `Promise`. If the API response lacks `trending`, returns `[]`. + +### `client.getTopMentions(filters?, options?)` + +Endpoint: `GET /analytics/top-mentions/` + +Filters include `limit?: number`, defaulting to `6`. + +Returns `Promise`. If the API response lacks `mentions`, returns `[]`. + +## Mention methods + +### `client.getMentions(filters?, optionsOrRevalidate?)` + +Endpoint: `GET /mentions/` + +Returns `Promise>`. + +`MentionFilters` include all `AnalyticsFilters` plus: + +- `page?: number` — page number, defaulting to `1`. +- `content_search?: string` — text search term. +- `ordering?: string` — sort key, such as `-published_at` or `-engagement_count`. + +The second argument may be `RequestOptions` or the legacy shorthand `number` for `revalidateSeconds`. + +## Mailing-list methods + +### `client.getMailingLists(options?)` + +Endpoint: `GET /mailing-lists/?page=1` + +Returns `Promise` from the first page. + +### `client.inviteMailingListMember(listId, invite, options?)` + +Endpoint: `POST /mailing-lists/{listId}/members/` + +`invite` fields: + +- `email: string` +- `first_name?: string` +- `last_name?: string` +- `invitation_message?: string` + +Returns `Promise`: + +- `{ outcome: 'invited', invitation }` for `201` plus `status: 'invited'`. +- `{ outcome: 'already', detail }` for `409` conflicts. +- `{ outcome: 'error', detail }` for unexpected successful/conflict response shapes. + +Other non-OK statuses throw `SocialhoseError`. + +## Entity analytics methods + +Entity analytics are SDK-composed views built on `/mentions/` with `content_search`. They provide term/person/org/topic analytics when native analytics endpoints are campaign-scoped. + +### `client.getEntityBrief(term, campaignId?, options?)` + +Runs one mention search ordered by engagement. + +Returns `Promise`: + +- `term` — requested search term. +- `total` — exact API count. +- `exact` — true when the sample covers the full population. +- `sentiment` — sample-derived sentiment split. +- `platformMix` — sample-derived platform counts. +- `sample` — first page ordered by engagement. + +`total` is exact. Sentiment and platform mix are exact only when `exact` is true. + +### `client.getEntityStats(term, campaignId?, options?)` + +Builds a richer dashboard for one term. + +Returns `Promise` with `EntityBrief` fields plus: + +- `recent` — newest mentions. +- `recent7d` — latest seven-day count from the sparkline. +- `prev7d` — previous seven-day count. +- `momentumPct` — seven-day percentage change, or `null` if not computable. +- `sparkline` — daily counts for the last 14 days when date faceting is trustworthy. + +Failure model: + +- The initial brief must succeed. +- Follow-up subrequests are best-effort. +- Exact facets are used only when reconciliation checks pass; otherwise sample-derived values are retained. + +### `client.getEntityBriefs(terms, campaignId?, concurrency?, options?)` + +Fetches many entity briefs with bounded concurrency. + +Returns `Promise>`. + +Failed terms are omitted from the map. Default concurrency is `20`; reduce it under rate limits. + +## Cache API + +### `Cache` + +```ts +interface Cache { + get(key: string): Promise; + set(key: string, value: unknown, ttlMs: number): Promise; + delete(key: string): Promise; +} +``` + +The key is the full GET URL. `get()` returns `undefined` on cache miss. `ttlMs` is in milliseconds. + +### `MemoryCache` + +In-memory `Map` cache with per-entry TTL. Good for tests, scripts, and single-process services. Not enough for distributed rate-limit protection. + +### `NoopCache` + +Cache implementation that never stores values. Use it to force fresh data or disable caching explicitly. + +### `client.clearCache()` + +Deletes cache keys used by the current client instance. It is not a global purge for shared cache backends. + +## `SocialhoseError` + +Structured request failure. + +Fields: + +- `name: 'SocialhoseError'` +- `status?: number` +- `path: string` +- `body?: string` +- `cause?: unknown` + +Thrown after retry exhaustion or unsupported non-OK HTTP responses. + +## Core data types + +- `Sentiment` — `'positive' | 'negative' | 'neutral'`. +- `SentimentSplit` — `{ positive: number; negative: number; neutral: number }`. +- `Paginated` — `{ count, next, previous, results }`. +- `Campaign` — campaign metadata. +- `Overview` — aggregate analytics. +- `TimelinePoint` — one time-series bucket. +- `ShareOfVoiceItem` — campaign comparison row. +- `PlatformStat` — platform count and engagement. +- `KeywordStat` — keyword count and sentiment. +- `TrendingItem` — keyword momentum. +- `TopMention` — compact high-impact mention. +- `Mention` — full mention record with content, engagement, metadata, and author profile. +- `MailingList` — mailing-list metadata. +- `MailingListInvitation` — mailing-list invitation state. +- `InviteResult` — normalized invite result. +- `PlatformShare` — entity analytics platform count. +- `EntityBrief` — compact term analytics. +- `EntityStats` — full term dashboard. diff --git a/sdks/javascript/docs/CACHING_AND_RETRIES.md b/sdks/javascript/docs/CACHING_AND_RETRIES.md new file mode 100644 index 0000000..9299fb7 --- /dev/null +++ b/sdks/javascript/docs/CACHING_AND_RETRIES.md @@ -0,0 +1,104 @@ +# Caching, Retries, and Failure Semantics + +## GET lifecycle + +1. Build full URL from `baseUrl`, `path`, and query params. +2. Check cache by full URL. +3. Fetch with auth headers and timeout on cache miss. +4. Retry network errors, timeout errors, `429`, and `5xx`. +5. Parse JSON. +6. Throw `SocialhoseError` for unsupported non-OK responses. +7. Store successful parsed response in cache. + +## POST lifecycle + +1. Build URL. +2. Fetch JSON body with auth headers and timeout. +3. Retry network errors, timeout errors, `429`, and `5xx`. +4. Parse JSON. +5. Return `{ status, data }`. +6. Never cache. + +## Retry policy + +Defaults: + +```ts +retries: 3, +retryDelayMs: (attempt) => 400 * 2 ** attempt + Math.random() * 200, +timeoutMs: 8_000, +``` + +Retries apply to transient conditions only: + +- network failures +- SDK timeout/abort failures +- `429 Too Many Requests` +- `5xx` server errors + +Retries do not apply to normal client errors such as `400`, `401`, `403`, and `404`. + +## Cache contract + +```ts +interface Cache { + get(key: string): Promise; + set(key: string, value: unknown, ttlMs: number): Promise; + delete(key: string): Promise; +} +``` + +The cache key is the full request URL. `ttlMs` is in milliseconds and comes from `cacheTtlMs` or per-request `revalidateSeconds`. + +## Redis-style cache example + +```ts +import { SocialhoseClient, type Cache } from '@socialhose/api'; + +class RedisJsonCache implements Cache { + constructor(private redis: { + get(k: string): Promise; + set(k: string, v: string, mode: 'PX', ttl: number): Promise; + del(k: string): Promise; + }) {} + + async get(key: string) { + const value = await this.redis.get(key); + return value == null ? undefined : JSON.parse(value); + } + + async set(key: string, value: unknown, ttlMs: number) { + if (ttlMs <= 0) return; + await this.redis.set(key, JSON.stringify(value), 'PX', ttlMs); + } + + async delete(key: string) { + await this.redis.del(key); + } +} + +const socialhose = new SocialhoseClient({ + apiKey: process.env.SOCIALHOSE_API_KEY!, + cache: new RedisJsonCache(redis), +}); +``` + +## Method failure semantics + +Campaign, analytics, mention, and list methods: + +- Throw `SocialhoseError` after retry exhaustion or unsupported non-OK responses. +- Return normalized arrays when the API wraps arrays in properties such as `series`, `platforms`, or `keywords`. + +`inviteMailingListMember()`: + +- `201 + status: invited` -> `outcome: 'invited'`. +- `409` -> `outcome: 'already'`. +- Unexpected success/conflict shapes -> `outcome: 'error'`. +- Other non-OK statuses throw. + +Entity methods: + +- `getEntityBrief()` throws if the mention search fails. +- `getEntityStats()` requires the initial brief but treats later subrequests as best-effort. +- `getEntityBriefs()` skips failed terms and returns successful entries. diff --git a/sdks/javascript/docs/ENTITY_ANALYTICS.md b/sdks/javascript/docs/ENTITY_ANALYTICS.md new file mode 100644 index 0000000..1a4de61 --- /dev/null +++ b/sdks/javascript/docs/ENTITY_ANALYTICS.md @@ -0,0 +1,85 @@ +# Entity Analytics + +Entity analytics are SDK-composed analytics for a single term, person, organization, hashtag, product, or incident. + +They exist because Socialhose analytics endpoints are campaign-scoped, while `/mentions/` supports `content_search` and returns an exact `count`. + +## `getEntityBrief()` + +`getEntityBrief(term, campaignId?)` performs one mention search ordered by engagement. + +It returns: + +- exact total mention count +- top mention sample +- sample-derived sentiment split +- sample-derived platform mix +- `exact` flag indicating whether the sample covers the full population + +This is cheap enough for grids, tables, and autocomplete-like entity lists. + +## `getEntityStats()` + +`getEntityStats(term, campaignId?)` expands a brief into a detail dashboard. + +It fetches: + +- engagement-ordered sample +- newest mentions +- sentiment facets +- platform facets +- cumulative date boundaries for a 14-day sparkline + +The first brief request is required. Subrequests are best-effort; failures return fallbacks instead of zeroing out the entity. + +## Accuracy safeguards + +### Sentiment reconciliation + +The SDK facets `/mentions/` by sentiment and accepts exact sentiment only when: + +```txt +positive + negative + neutral === total +``` + +If the sum does not match, the SDK assumes a filter was ignored or misapplied and falls back to sample-derived sentiment. + +### Platform reconciliation + +The SDK facets `/mentions/` over known platform keys. If the platform facet sum exceeds the entity total, the SDK assumes the API ignored a filter and falls back to sample-derived platform mix. + +A sum below total is allowed because some mentions may be on platforms outside the SDK's known list. + +### Timeline cumulative differencing + +The SDK avoids adjacent `[date_from, date_to]` daily windows because inclusive `date_to` can double-count boundary days. + +Instead it requests cumulative counts with `date_from` only: + +```txt +count(on/after day N) - count(on/after day N+1) = day N count +``` + +If the earliest cumulative count exceeds the known entity total, the SDK treats date-filtered `content_search` as untrustworthy and returns an empty sparkline rather than wrong bars. + +## Rate-limit guidance + +A single `getEntityStats()` can perform approximately: + +- 1 brief request +- 1 recent request +- 3 sentiment facet requests +- 6 platform facet requests +- 15 timeline boundary requests + +Use caching and keep batch concurrency low. + +Recommended pattern: + +```ts +const stats = await socialhose.getEntityStats('cholera', campaignId, { + revalidateSeconds: 900, +}); +``` + +Use `getEntityBriefs()` for lists and call `getEntityStats()` only for selected detail views. diff --git a/sdks/javascript/docs/GUIDE.md b/sdks/javascript/docs/GUIDE.md new file mode 100644 index 0000000..e29c274 --- /dev/null +++ b/sdks/javascript/docs/GUIDE.md @@ -0,0 +1,173 @@ +# Usage Guide + +## What this SDK is + +`@socialhose/api` is a TypeScript client for the Socialhose Public API. It is built for backend services, scripts, serverless functions, and dashboards that need campaign analytics, mention search, mailing-list management, and term-level entity analytics. + +The SDK handles: + +1. API-key authentication. +2. Typed endpoint helpers. +3. Timeouts, retries, and request cancellation. +4. GET caching with an injectable cache interface. +5. Entity analytics assembled from `/mentions/` when native analytics endpoints are campaign-scoped. + +## Install + +```bash +npm install @socialhose/api +``` + +Requires Node 18+ or a custom `fetch` implementation. + +## Basic setup + +```ts +import { SocialhoseClient } from '@socialhose/api'; + +const socialhose = new SocialhoseClient({ + apiKey: process.env.SOCIALHOSE_API_KEY!, +}); +``` + +Keep the API key server-side. Do not instantiate this client in browser code. + +## Production setup + +```ts +const socialhose = new SocialhoseClient({ + apiKey: process.env.SOCIALHOSE_API_KEY!, + timeoutMs: 10_000, + retries: 3, + cacheTtlMs: 60_000, +}); +``` + +Recommendations: + +- Keep the default browser-like user-agent unless you have verified an alternative works. +- Use a shared cache such as Redis for multi-process or serverless deployments. +- Reduce entity batch concurrency if you see `429` responses. +- Use `revalidateSeconds` for dashboards so repeated views do not fan out fresh requests. + +## List campaigns + +```ts +const campaigns = await socialhose.getCampaigns(); +for (const campaign of campaigns) { + console.log(campaign.id, campaign.name, campaign.status); +} +``` + +## Search mentions + +```ts +const page = await socialhose.getMentions({ + campaign_ids: 'campaign-id', + content_search: 'hospital', + platforms: 'twitter,reddit', + sentiments: 'negative', + ordering: '-published_at', +}); + +console.log(page.count); +console.log(page.results[0]?.content); +``` + +## Fetch campaign dashboard analytics + +```ts +const filters = { campaign_ids: 'campaign-id', date_from: '2026-05-01' }; + +const [overview, timeline, sentiment, platforms, topMentions] = await Promise.all([ + socialhose.getOverview(filters), + socialhose.getTimeline({ ...filters, interval: 'day' }), + socialhose.getSentiment(filters), + socialhose.getPlatforms(filters), + socialhose.getTopMentions({ ...filters, limit: 10 }), +]); +``` + +## Analyze a term or entity + +```ts +const stats = await socialhose.getEntityStats('RSF', 'campaign-id', { + revalidateSeconds: 900, +}); + +console.log({ + total: stats.total, + sentiment: stats.sentiment, + platformMix: stats.platformMix, + momentumPct: stats.momentumPct, + topUrls: stats.sample.slice(0, 3).map((m) => m.url), +}); +``` + +Use `getEntityBrief()` for cheap cards/lists and `getEntityStats()` for detail pages. + +## Batch entity briefs + +```ts +const briefs = await socialhose.getEntityBriefs( + ['Burhan', 'Hemedti', 'SAF', 'RSF'], + 'campaign-id', + 5, + { revalidateSeconds: 3600 }, +); +``` + +Failed terms are skipped. Lower concurrency if rate limits are visible. + +## Pagination + +`getCampaigns()` and `getMailingLists()` return first-page arrays. `getMentions()` exposes a `page` filter. + +For manual pagination, use `get()`: + +```ts +let page = 1; +while (true) { + const response = await socialhose.get('/mentions/', { page, content_search: 'cholera' }); + // process response.results + if (!response.next) break; + page += 1; +} +``` + +## Error handling + +```ts +import { SocialhoseError } from '@socialhose/api'; + +try { + await socialhose.getOverview({ campaign_ids: 'bad-id' }); +} catch (error) { + if (error instanceof SocialhoseError) { + console.error({ status: error.status, path: error.path, body: error.body }); + } else { + throw error; + } +} +``` + +## Caching + +GET requests are cached; POST requests are not. + +```ts +await socialhose.getMentions( + { content_search: 'sudan' }, + { revalidateSeconds: 900 }, +); +``` + +The default cache is process-local. Use a persistent/shared cache for production dashboards and rate-limit control. + +## Operational pitfalls + +- Entity analytics are request-heavy. One `getEntityStats()` call can issue roughly 20+ requests. +- The API rate limit is roughly 60 requests/minute per API key. +- The entity timeline uses cumulative differencing intentionally to avoid inclusive `date_to` double-counting. +- Exact entity sentiment/platform values are used only when facet counts reconcile with the known total. +- `409` mailing-list conflicts are normalized to `{ outcome: 'already' }`; other non-OK responses throw. diff --git a/sdks/javascript/examples/basic.ts b/sdks/javascript/examples/basic.ts new file mode 100644 index 0000000..042b3ff --- /dev/null +++ b/sdks/javascript/examples/basic.ts @@ -0,0 +1,29 @@ +import { SocialhoseClient, SocialhoseError } from '@socialhose/api'; + +const socialhose = new SocialhoseClient({ + apiKey: process.env.SOCIALHOSE_API_KEY!, +}); + +try { + const campaigns = await socialhose.getCampaigns(); + const campaignId = campaigns[0]?.id; + + const mentions = await socialhose.getMentions({ + campaign_ids: campaignId, + content_search: 'hospital', + ordering: '-published_at', + }); + + console.log({ + campaigns: campaigns.length, + mentionCount: mentions.count, + firstMention: mentions.results[0]?.url, + }); +} catch (error) { + if (error instanceof SocialhoseError) { + console.error({ status: error.status, path: error.path, body: error.body }); + process.exitCode = 1; + } else { + throw error; + } +} diff --git a/sdks/javascript/examples/entity-dashboard.ts b/sdks/javascript/examples/entity-dashboard.ts new file mode 100644 index 0000000..53f8adc --- /dev/null +++ b/sdks/javascript/examples/entity-dashboard.ts @@ -0,0 +1,26 @@ +import { SocialhoseClient } from '@socialhose/api'; + +const socialhose = new SocialhoseClient({ + apiKey: process.env.SOCIALHOSE_API_KEY!, + cacheTtlMs: 15 * 60 * 1000, +}); + +const campaignId = process.argv[2]; +const term = process.argv[3] ?? 'RSF'; + +const stats = await socialhose.getEntityStats(term, campaignId, { + revalidateSeconds: 15 * 60, +}); + +console.log(JSON.stringify({ + term: stats.term, + total: stats.total, + exact: stats.exact, + sentiment: stats.sentiment, + platformMix: stats.platformMix, + recent7d: stats.recent7d, + prev7d: stats.prev7d, + momentumPct: stats.momentumPct, + sparkline: stats.sparkline, + topUrls: stats.sample.slice(0, 5).map((m) => m.url), +}, null, 2)); diff --git a/sdks/javascript/package.json b/sdks/javascript/package.json index 05b1dd0..5181475 100644 --- a/sdks/javascript/package.json +++ b/sdks/javascript/package.json @@ -7,6 +7,8 @@ "sideEffects": false, "files": [ "dist", + "docs", + "examples", "README.md", "LICENSE" ], diff --git a/sdks/javascript/src/cache.ts b/sdks/javascript/src/cache.ts index 50f8b04..3ae20fd 100644 --- a/sdks/javascript/src/cache.ts +++ b/sdks/javascript/src/cache.ts @@ -1,12 +1,22 @@ +/** + * Pluggable cache contract used by SocialhoseClient for GET responses. + * The SDK uses the full request URL as the key. + */ export interface Cache { + /** Return a cached value, or `undefined` on miss/expiry. */ get(key: string): Promise; + /** Store a value for the given TTL in milliseconds. */ set(key: string, value: unknown, ttlMs: number): Promise; + /** Delete one cache entry. */ delete(key: string): Promise; } type Entry = { at: number; value: unknown; ttlMs: number }; -/** In-memory Map-backed cache with per-entry TTL. */ +/** + * In-memory Map-backed cache with per-entry TTL. + * Best for tests, scripts, and single-process services. + */ export class MemoryCache implements Cache { private readonly map = new Map(); @@ -29,7 +39,7 @@ export class MemoryCache implements Cache { } } -/** No-op cache — all operations are no-ops; useful for disabling caching. */ +/** Cache implementation that never stores values; useful for disabling caching. */ export class NoopCache implements Cache { async get(_key: string): Promise { return undefined; diff --git a/sdks/javascript/src/index.ts b/sdks/javascript/src/index.ts index 78790f0..0228e75 100644 --- a/sdks/javascript/src/index.ts +++ b/sdks/javascript/src/index.ts @@ -2,11 +2,13 @@ export type { Cache } from './cache'; export { MemoryCache, NoopCache } from './cache'; import { type Cache, MemoryCache, NoopCache } from './cache'; +/** Sentiment label assigned to a mention by Socialhose. */ 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; +/** Configuration for {@link SocialhoseClient}. */ export interface SocialhoseClientOptions { apiKey: string; baseUrl?: string; @@ -21,6 +23,7 @@ export interface SocialhoseClientOptions { defaultHeaders?: Record; } +/** Per-call controls for cache TTL, cancellation, and extra headers. */ export interface RequestOptions { /** * Reserved for cache-implementation use; not forwarded to fetch. @@ -226,6 +229,7 @@ export interface EntityStats extends EntityBrief { sparkline: { date: string; count: number }[]; } +/** Structured error thrown for failed Socialhose requests. */ export class SocialhoseError extends Error { readonly status?: number; readonly path: string; @@ -312,6 +316,7 @@ function platformMixOf(mentions: Mention[]): PlatformShare[] { .sort((a, b) => b.count - a.count); } +/** Authenticated server-side client for the Socialhose Public API. */ export class SocialhoseClient { readonly apiKey: string; readonly baseUrl: string; @@ -326,6 +331,7 @@ export class SocialhoseClient { private readonly cacheImpl: Cache; private readonly usedCacheKeys = new Set(); + /** Create a client with authentication, retry, timeout, and cache settings. */ constructor(options: SocialhoseClientOptions) { if (!options.fetch && typeof fetch === 'undefined') throw new Error('A fetch implementation is required'); this.apiKey = options.apiKey; @@ -340,12 +346,14 @@ export class SocialhoseClient { this.cacheImpl = options.cache ?? (this.cacheTtlMs > 0 ? new MemoryCache() : new NoopCache()); } + /** Delete cache entries used by this client instance. */ async clearCache(): Promise { const keys = [...this.usedCacheKeys]; this.usedCacheKeys.clear(); await Promise.all(keys.map((k) => this.cacheImpl.delete(k))); } + /** Perform an authenticated cached GET request. */ async get(path: string, params: QueryParams = {}, options: RequestOptions = {}): Promise { const query = encodeParams(params); const url = `${joinUrl(this.baseUrl, path)}${query ? `?${query}` : ''}`; @@ -360,20 +368,24 @@ export class SocialhoseClient { return value; } + /** Perform an authenticated JSON POST request. */ 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); } + /** Fetch the first page of campaigns. */ async getCampaigns(options: RequestOptions = {}): Promise { const d = await this.get>('/campaigns/', { page: 1 }, options); return d.results; } + /** Fetch a single campaign by ID. */ getCampaign(id: string, options: RequestOptions = {}): Promise { return this.get(`/campaigns/${id}/`, {}, options); } + /** Fetch aggregate overview analytics. */ 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) }; @@ -745,6 +757,7 @@ export class SocialhoseClient { } } +/** Create a {@link SocialhoseClient}; convenience wrapper around the constructor. */ export function createSocialhoseClient(options: SocialhoseClientOptions): SocialhoseClient { return new SocialhoseClient(options); }