feat: add JavaScript Socialhose API SDK
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
name: JavaScript SDK
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'sdks/javascript/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.github/workflows/javascript.yml'
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'sdks/javascript/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.github/workflows/javascript.yml'
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9.15.0
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22.13.0'
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm --filter @socialhose/api test
|
||||||
|
- run: pnpm --filter @socialhose/api typecheck
|
||||||
|
- run: pnpm --filter @socialhose/api build
|
||||||
|
- run: npm pack --pack-destination /tmp
|
||||||
|
working-directory: sdks/javascript
|
||||||
|
|
||||||
|
publish-js:
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9.15.0
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22.13.0'
|
||||||
|
registry-url: https://registry.npmjs.org
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm --filter @socialhose/api publish --access public --provenance --no-git-checks
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.tgz
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Socialhose SDKs
|
||||||
|
|
||||||
|
Official SDKs for the Socialhose Public API.
|
||||||
|
|
||||||
|
## SDKs
|
||||||
|
|
||||||
|
- `sdks/javascript` — npm package `@socialhose/api`
|
||||||
|
|
||||||
|
Future SDKs can live beside it, e.g. `sdks/python`, `sdks/go`, or `sdks/php`.
|
||||||
|
|
||||||
|
## JavaScript / TypeScript
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @socialhose/api
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SocialhoseClient } from '@socialhose/api';
|
||||||
|
|
||||||
|
const socialhose = new SocialhoseClient({
|
||||||
|
apiKey: process.env.SOCIALHOSE_API_KEY!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mentions = await socialhose.getMentions({
|
||||||
|
content_search: 'hospital',
|
||||||
|
ordering: '-published_at',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(mentions.count);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Use pnpm 9.x.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm test
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
On machines where Corepack's pnpm shim is broken, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx --yes pnpm@9.15.0 install
|
||||||
|
npx --yes pnpm@9.15.0 test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishing JavaScript SDK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd sdks/javascript
|
||||||
|
pnpm test
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm build
|
||||||
|
npm publish --access public --provenance
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not publish `1.0.0` until the API endpoint inventory is complete and at least two real consumers have migrated.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "socialhose-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "SDKs for the Socialhose Public API.",
|
||||||
|
"packageManager": "pnpm@9.15.0",
|
||||||
|
"engines": { "node": ">=18" },
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm -r build",
|
||||||
|
"test": "pnpm -r test",
|
||||||
|
"typecheck": "pnpm -r typecheck",
|
||||||
|
"pack:js": "pnpm --filter @socialhose/api build && npm pack --pack-destination /tmp ./sdks/javascript"
|
||||||
|
},
|
||||||
|
"devDependencies": {}
|
||||||
|
}
|
||||||
Generated
+1662
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
- sdks/*
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Socialhose
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# @socialhose/api
|
||||||
|
|
||||||
|
TypeScript SDK for the Socialhose Public API.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SocialhoseClient } from '@socialhose/api';
|
||||||
|
|
||||||
|
const socialhose = new SocialhoseClient({
|
||||||
|
apiKey: process.env.SOCIALHOSE_API_KEY!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mentions = await socialhose.getMentions({
|
||||||
|
content_search: 'hospital',
|
||||||
|
platforms: 'twitter',
|
||||||
|
ordering: '-published_at',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(mentions.count, mentions.results[0]?.content);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const socialhose = new SocialhoseClient({
|
||||||
|
apiKey: process.env.SOCIALHOSE_API_KEY!,
|
||||||
|
baseUrl: 'https://socialhose.net/api/public/v1',
|
||||||
|
timeoutMs: 8_000,
|
||||||
|
retries: 3,
|
||||||
|
cacheTtlMs: 60_000,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The SDK sends `Authorization: Api-Key <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
|
||||||
|
|
||||||
|
- `getCampaigns()`
|
||||||
|
- `getCampaign(id)`
|
||||||
|
- `getOverview(filters)`
|
||||||
|
- `getTimeline(filters)`
|
||||||
|
- `getSentiment(filters)`
|
||||||
|
- `getShareOfVoice(filters)`
|
||||||
|
- `getPlatforms(filters)`
|
||||||
|
- `getTopKeywords(filters)`
|
||||||
|
- `getTrending(filters)`
|
||||||
|
- `getTopMentions(filters)`
|
||||||
|
- `getMentions(filters)`
|
||||||
|
- `getMailingLists()`
|
||||||
|
- `inviteMailingListMember(listId, invite)`
|
||||||
|
- `get(path, params)` for lower-level GET access
|
||||||
|
- `post(path, body)` for lower-level POST access
|
||||||
|
|
||||||
|
## Filtering examples
|
||||||
|
|
||||||
|
```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',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next.js cache integration
|
||||||
|
|
||||||
|
Pass `revalidateSeconds` per request. In Next.js this is forwarded as `fetch(..., { next: { revalidate } })`; outside Next.js it is harmless.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await socialhose.getMentions({ content_search: 'ozempic' }, { revalidateSeconds: 3600 });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
Failed requests throw `SocialhoseError` with `status`, `path`, and response `body` when available.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SocialhoseError } from '@socialhose/api';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await socialhose.getCampaign('missing');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SocialhoseError) {
|
||||||
|
console.error(error.status, error.path, error.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm build
|
||||||
|
npm publish --access public --provenance
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@socialhose/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TypeScript SDK for the Socialhose Public API.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"files": ["dist", "README.md", "LICENSE"],
|
||||||
|
"main": "./dist/index.cjs",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" },
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"prepublishOnly": "pnpm test && pnpm typecheck && pnpm build"
|
||||||
|
},
|
||||||
|
"keywords": ["socialhose", "social-listening", "social-media", "public-api", "monitoring"],
|
||||||
|
"publishConfig": { "access": "public", "provenance": true },
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "20.16.0",
|
||||||
|
"tsup": "8.3.0",
|
||||||
|
"typescript": "5.6.2",
|
||||||
|
"vitest": "2.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { SocialhoseClient, SocialhoseError } from '../src/index';
|
||||||
|
|
||||||
|
const ok = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
describe('SocialhoseClient', () => {
|
||||||
|
it('sends Api-Key auth, browser-like user-agent, and query params', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ok({ count: 0, next: null, previous: null, results: [] }));
|
||||||
|
const client = new SocialhoseClient({ apiKey: 'test-key', fetch: fetchMock, cacheTtlMs: 0 });
|
||||||
|
|
||||||
|
await client.getMentions({ page: 2, platforms: 'twitter', content_search: 'hospital' });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
|
expect(url).toBe(
|
||||||
|
'https://socialhose.net/api/public/v1/mentions/?page=2&platforms=twitter&content_search=hospital',
|
||||||
|
);
|
||||||
|
expect(init.headers).toMatchObject({
|
||||||
|
Authorization: 'Api-Key test-key',
|
||||||
|
Accept: 'application/json',
|
||||||
|
});
|
||||||
|
expect((init.headers as Record<string, string>)['User-Agent']).toContain('Mozilla/5.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches identical GET requests inside the configured TTL', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ok({ count: 1, next: null, previous: null, results: [] }));
|
||||||
|
const client = new SocialhoseClient({ apiKey: 'test-key', fetch: fetchMock, cacheTtlMs: 60_000 });
|
||||||
|
|
||||||
|
await client.getMentions({ page: 1 });
|
||||||
|
await client.getMentions({ page: 1 });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries rate limits and transient server failures', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(new Response('rate limited', { status: 429 }))
|
||||||
|
.mockResolvedValueOnce(new Response('bad gateway', { status: 502 }))
|
||||||
|
.mockResolvedValueOnce(ok({ results: [] }));
|
||||||
|
const client = new SocialhoseClient({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
fetch: fetchMock,
|
||||||
|
cacheTtlMs: 0,
|
||||||
|
retryDelayMs: () => 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const campaigns = await client.getCampaigns();
|
||||||
|
|
||||||
|
expect(campaigns).toEqual([]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws a structured SocialhoseError for non-ok responses', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => new Response('{"detail":"forbidden"}', { status: 403 }));
|
||||||
|
const client = new SocialhoseClient({ apiKey: 'test-key', fetch: fetchMock, cacheTtlMs: 0 });
|
||||||
|
|
||||||
|
await expect(client.getCampaign('abc')).rejects.toMatchObject({
|
||||||
|
name: 'SocialhoseError',
|
||||||
|
status: 403,
|
||||||
|
path: '/campaigns/abc/',
|
||||||
|
} satisfies Partial<SocialhoseError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes mailing-list invite outcomes', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ok({ detail: 'already invited' }, 409));
|
||||||
|
const client = new SocialhoseClient({ apiKey: 'test-key', fetch: fetchMock });
|
||||||
|
|
||||||
|
await expect(client.inviteMailingListMember('list-1', { email: 'a@example.com' })).resolves.toEqual({
|
||||||
|
outcome: 'already',
|
||||||
|
detail: 'already invited',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers missing apiKey errors until the first request', async () => {
|
||||||
|
const client = new SocialhoseClient({ apiKey: '', fetch: vi.fn() });
|
||||||
|
|
||||||
|
await expect(client.getCampaigns()).rejects.toThrow('Socialhose apiKey is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user