|
| 1 | +// @profullstack/crawlproof-social |
| 2 | +// |
| 3 | +// Typed HTTP client for crawlproof.com's social posting API: |
| 4 | +// GET /api/sp/v1/accounts — list a user's connected social accounts |
| 5 | +// POST /api/sp/v1/posts — publish a post via one of those accounts |
| 6 | +// |
| 7 | +// Auth: Bearer token issued at https://crawlproof.com/social/api-tokens. |
| 8 | +// |
| 9 | +// Usage: |
| 10 | +// import { createCrawlproofSocialClient } from '@profullstack/crawlproof-social'; |
| 11 | +// const client = createCrawlproofSocialClient({ token: process.env.CRAWLPROOF_API_TOKEN! }); |
| 12 | +// const accounts = await client.listAccounts(); |
| 13 | +// const result = await client.post({ accountId: accounts[0].id, text: 'hello world' }); |
| 14 | + |
| 15 | +export type Platform = |
| 16 | + | 'bluesky' |
| 17 | + | 'reddit' |
| 18 | + | 'mastodon' |
| 19 | + | 'linkedin' |
| 20 | + | 'x' |
| 21 | + | 'facebook_page' |
| 22 | + | 'threads' |
| 23 | + | 'discord' |
| 24 | + | 'telegram' |
| 25 | + // Schema-supported, posting-not-yet-implemented: |
| 26 | + | 'pinterest' |
| 27 | + | 'instagram' |
| 28 | + | 'instagram_business' |
| 29 | + | 'tiktok' |
| 30 | + | 'youtube' |
| 31 | + | 'tumblr' |
| 32 | + | 'snapchat'; |
| 33 | + |
| 34 | +export type SocialAccount = { |
| 35 | + id: string; |
| 36 | + platform: Platform; |
| 37 | + handle: string; |
| 38 | + status: 'active' | 'token_expired' | 'suspended_by_platform' | 'user_disabled' | 'flagged'; |
| 39 | + instance_url: string | null; |
| 40 | + last_post_at: string | null; |
| 41 | + created_at: string; |
| 42 | +}; |
| 43 | + |
| 44 | +export type PostInput = { |
| 45 | + accountId: string; |
| 46 | + text: string; |
| 47 | + // Reddit-only — required when posting to a reddit account. |
| 48 | + subreddit?: string; |
| 49 | + title?: string; |
| 50 | +}; |
| 51 | + |
| 52 | +export type PostResult = { |
| 53 | + postId: string; // sp_post.id on crawlproof |
| 54 | + platformPostId: string; // platform-native id |
| 55 | + webUrl: string; |
| 56 | +}; |
| 57 | + |
| 58 | +export type CrawlproofSocialClient = { |
| 59 | + listAccounts(): Promise<SocialAccount[]>; |
| 60 | + post(input: PostInput): Promise<PostResult>; |
| 61 | +}; |
| 62 | + |
| 63 | +export type ClientOptions = { |
| 64 | + token: string; |
| 65 | + // Defaults to https://crawlproof.com. Override for self-hosted or |
| 66 | + // staging environments. |
| 67 | + baseUrl?: string; |
| 68 | + // Override the global fetch (e.g. inject undici, an instrumented |
| 69 | + // fetch). Defaults to globalThis.fetch. |
| 70 | + fetch?: typeof fetch; |
| 71 | +}; |
| 72 | + |
| 73 | +const DEFAULT_BASE_URL = 'https://crawlproof.com'; |
| 74 | + |
| 75 | +export class CrawlproofSocialError extends Error { |
| 76 | + status: number; |
| 77 | + body: unknown; |
| 78 | + constructor(status: number, message: string, body: unknown) { |
| 79 | + super(message); |
| 80 | + this.name = 'CrawlproofSocialError'; |
| 81 | + this.status = status; |
| 82 | + this.body = body; |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +export function createCrawlproofSocialClient( |
| 87 | + options: ClientOptions, |
| 88 | +): CrawlproofSocialClient { |
| 89 | + if (!options.token) { |
| 90 | + throw new Error('crawlproof-social: token is required'); |
| 91 | + } |
| 92 | + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); |
| 93 | + const fetchImpl = options.fetch ?? globalThis.fetch; |
| 94 | + if (typeof fetchImpl !== 'function') { |
| 95 | + throw new Error('crawlproof-social: no fetch implementation available'); |
| 96 | + } |
| 97 | + const headers = { |
| 98 | + authorization: `Bearer ${options.token}`, |
| 99 | + 'content-type': 'application/json', |
| 100 | + }; |
| 101 | + |
| 102 | + async function call<T>(path: string, init: RequestInit = {}): Promise<T> { |
| 103 | + const res = await fetchImpl(`${baseUrl}${path}`, { |
| 104 | + ...init, |
| 105 | + headers: { ...headers, ...(init.headers ?? {}) }, |
| 106 | + }); |
| 107 | + let body: unknown = null; |
| 108 | + try { |
| 109 | + body = await res.json(); |
| 110 | + } catch { |
| 111 | + // non-JSON response; fall through with body = null |
| 112 | + } |
| 113 | + if (!res.ok) { |
| 114 | + const message = |
| 115 | + body && typeof body === 'object' && 'error' in body |
| 116 | + ? String((body as { error: unknown }).error) |
| 117 | + : `${res.status} ${res.statusText}`; |
| 118 | + throw new CrawlproofSocialError(res.status, message, body); |
| 119 | + } |
| 120 | + return body as T; |
| 121 | + } |
| 122 | + |
| 123 | + return { |
| 124 | + async listAccounts(): Promise<SocialAccount[]> { |
| 125 | + const r = await call<{ accounts: SocialAccount[] }>('/api/sp/v1/accounts', { |
| 126 | + method: 'GET', |
| 127 | + }); |
| 128 | + return r.accounts; |
| 129 | + }, |
| 130 | + async post(input: PostInput): Promise<PostResult> { |
| 131 | + const r = await call<{ |
| 132 | + post_id: string; |
| 133 | + platform_post_id: string; |
| 134 | + web_url: string; |
| 135 | + }>('/api/sp/v1/posts', { |
| 136 | + method: 'POST', |
| 137 | + body: JSON.stringify({ |
| 138 | + account_id: input.accountId, |
| 139 | + text: input.text, |
| 140 | + subreddit: input.subreddit, |
| 141 | + title: input.title, |
| 142 | + }), |
| 143 | + }); |
| 144 | + return { |
| 145 | + postId: r.post_id, |
| 146 | + platformPostId: r.platform_post_id, |
| 147 | + webUrl: r.web_url, |
| 148 | + }; |
| 149 | + }, |
| 150 | + }; |
| 151 | +} |
0 commit comments