From 0dd9f782ade04458f7977ce5b836395cf86fb31b Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 27 Aug 2026 14:40:18 +0300 Subject: [PATCH] feat: add growth sources analytics and resilient subscriber stats fallback --- package.json | 2 +- src/core/client.ts | 17 ++++- src/core/index.ts | 7 ++ src/core/types.ts | 58 ++++++++++++++++ src/mcp-server.ts | 66 +++++++++++++++++- src/resources/growth/index.ts | 22 ++++++ src/resources/subscriber-stats/index.ts | 41 +++++++++--- test/client.test.ts | 89 +++++++++++++++++++++++++ test/mcp.test.ts | 56 ++++++++++++++++ 9 files changed, 345 insertions(+), 13 deletions(-) create mode 100644 src/resources/growth/index.ts diff --git a/package.json b/package.json index 0d053bb..d636e7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "unofficial-substack-sdk", - "version": "0.3.4", + "version": "0.3.5", "description": "Unofficial, portable TypeScript SDK for Substack's web API", "type": "module", "license": "MIT", diff --git a/src/core/client.ts b/src/core/client.ts index d071b08..f3fbcf6 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -11,6 +11,9 @@ import type { EmailStatsPage, EmailStatsRow, FetchLike, + GrowthSourceItem, + GrowthSourcesOptions, + GrowthSourcesResponse, NoteComment, NoteCommentOptions, NoteFeedItem, @@ -36,6 +39,7 @@ import type { } from './types.js' import { getActivity, getUnreadActivity } from '../resources/activity/index.js' import { getAllEmailStats, getEmailStats } from '../resources/email-stats/index.js' +import { getGrowthSources } from '../resources/growth/index.js' import { commentOnNote, createAttachment, @@ -292,10 +296,21 @@ export class SubstackClient { * Returns the publication's subscriber records and aggregate subscriber count. * The response may include subscriber personal data, including email addresses. */ - getSubscriberStats(): Promise> { + getSubscriberStats(): Promise | Record> { return getSubscriberStats(this.endpoints) } + /** + * Returns historical traffic, subscriber growth, and revenue breakdown by acquisition source. + * A publication URL is required. + */ + getGrowthSources( + options: GrowthSourcesOptions = {} + ): Promise> { + this.requirePublicationApiBase() + return getGrowthSources(this.endpoints, options) + } + /** * Creates a link attachment for a Note. Pass the returned attachment ID to * `publishNote` as an `attachmentIds` entry. diff --git a/src/core/index.ts b/src/core/index.ts index 1de4b5b..99e885a 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -11,10 +11,17 @@ export { type CursorOptions, type DraftNotesOptions, type DraftNotesPage, + type EmailStatsItem, type EmailStatsOptions, type EmailStatsPage, type EmailStatsRow, type FetchLike, + type GrowthMetric, + type GrowthMetricTimeseriesPoint, + type GrowthSourceItem, + type GrowthSourcesOptions, + type GrowthSourcesResponse, + type GrowthTotalItem, type NoteActionOptions, type NoteBodyInlineNode, type NoteBodyJson, diff --git a/src/core/types.ts b/src/core/types.ts index 767705f..7d03420 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -252,6 +252,9 @@ export interface EmailStatsOptions { orderDirection?: 'asc' | 'desc' } +/** One raw row from the publication email statistics endpoint. */ +export type EmailStatsItem = EmailStatsRow + /** One raw row from the publication email statistics endpoint. */ export interface EmailStatsRow { post_id?: number @@ -469,3 +472,58 @@ export type UnreadActivityFeed = ActivityFeed & { activityItems: unknown[] unread: UnreadActivityMetadata } + +export interface GrowthSourcesOptions { + /** Start date in YYYY-MM-DD format. */ + fromDate?: string + /** End date in YYYY-MM-DD format. */ + toDate?: string + /** Upstream metric to order by. Defaults to `users`. */ + orderBy?: 'users' | 'subscriptions' | 'annual_subscriptions' | 'revenue' | string + /** Sort direction. Defaults to `desc`. */ + orderDirection?: 'asc' | 'desc' +} + +export interface GrowthMetricTimeseriesPoint { + date: string + value: number + [key: string]: unknown +} + +export interface GrowthMetric { + name: 'Traffic' | 'Subscribers' | 'Revenue' | string + timeseries?: GrowthMetricTimeseriesPoint[] + total?: number + [key: string]: unknown +} + +export interface GrowthSourceItem { + source?: string + sourceName?: string + originalSourceName?: string + category?: string + logoUrl?: string + href?: string + pubId?: number + noteId?: string + isAggregation?: boolean + metrics?: GrowthMetric[] + children?: GrowthSourceItem[] + [key: string]: unknown +} + +export interface GrowthTotalItem { + name?: 'traffic' | 'subscribers' | 'revenue' | string + total?: number + [key: string]: unknown +} + +/** Response from Substack's publication growth sources endpoint. */ +export type GrowthSourcesResponse< + TSource = GrowthSourceItem, + TTotal = GrowthTotalItem +> = { + sourceMetrics?: TSource[] + totals?: TTotal[] + [key: string]: unknown +} diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 424e309..c04a197 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -5,7 +5,8 @@ import { SubstackClient, SubstackConfigurationError, type EmailStatsOptions, - type EmailStatsRow + type EmailStatsRow, + type GrowthSourcesOptions } from './index.js' type ReadOnlyClient = Pick< @@ -22,6 +23,7 @@ type ReadOnlyClient = Pick< | 'getSubscriberStats' | 'getActivity' | 'getUnreadActivity' + | 'getGrowthSources' > type ToolResult = { @@ -47,6 +49,9 @@ const emailRowLimit = z.number().int().min(1).max(20).default(20) const rawRowLimit = z.number().int().min(1).max(200).default(20) const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD.') const orderDirection = z.enum(['asc', 'desc']).default('desc') +const growthSourcesOrderBy = z + .enum(['users', 'subscriptions', 'annual_subscriptions', 'revenue']) + .default('users') const activityFilter = z.enum(['all', 'replies-and-mentions', 'restacks']).default('all') const topMetrics = [ 'opens', @@ -373,12 +378,33 @@ export function createToolHandlers(client: ReadOnlyClient) { run(async () => capped(await client.getActivity(filter), maximum)), getUnreadActivity: (maximum = 20) => run(async () => capped(await client.getUnreadActivity(), maximum)), - analyzeContent: (postId: string | number) => getPostAnalytics(postId, 0, false) + analyzeContent: (postId: string | number) => getPostAnalytics(postId, 0, false), + getSubscriberStats: async (): Promise => { + try { + const stats = await client.getSubscriberStats() + return { + content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }], + structuredContent: stats as Record + } + } catch (error: any) { + return { + isError: true, + content: [{ type: 'text', text: `Failed to fetch subscriber stats: ${error.message}` }] + } + } + }, + getGrowthSources: (options: GrowthSourcesOptions = {}) => + run(async () => { + if (options.fromDate && options.toDate && options.fromDate > options.toDate) { + throw new SubstackConfigurationError('Growth sources fromDate cannot be after toDate.') + } + return client.getGrowthSources(options) + }) } } export function createMcpServer(client: ReadOnlyClient): McpServer { - const server = new McpServer({ name: 'substack-mcp', version: '0.3.4' }) + const server = new McpServer({ name: 'substack-mcp', version: '0.3.5' }) const tools = createToolHandlers(client) server.registerTool( @@ -545,6 +571,17 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { }, ({ include_records, record_limit }) => tools.getSubscriberSummary(include_records, record_limit) ) + server.registerTool( + 'get_subscriber_stats', + { + title: 'Get subscriber stats', + description: + 'Get publication subscriber statistics or delivery-derived stats if subscriber-stats is unavailable.', + outputSchema, + annotations: readOnlyAnnotations + }, + () => tools.getSubscriberStats() + ) server.registerTool( 'get_activity', { @@ -579,6 +616,29 @@ export function createMcpServer(client: ReadOnlyClient): McpServer { }, ({ post_id }) => tools.analyzeContent(post_id) ) + server.registerTool( + 'get_growth_sources', + { + title: 'Get growth and traffic sources', + description: + 'Get historical breakdown of publication traffic, subscriber acquisition, and revenue by referrer / growth channel over a date range.', + inputSchema: { + from_date: date.optional(), + to_date: date.optional(), + order_by: growthSourcesOrderBy, + order_direction: orderDirection + }, + outputSchema, + annotations: readOnlyAnnotations + }, + ({ from_date, to_date, order_by, order_direction }) => + tools.getGrowthSources({ + fromDate: from_date, + toDate: to_date, + orderBy: order_by, + orderDirection: order_direction + }) + ) return server } diff --git a/src/resources/growth/index.ts b/src/resources/growth/index.ts new file mode 100644 index 0000000..1bd49dc --- /dev/null +++ b/src/resources/growth/index.ts @@ -0,0 +1,22 @@ +import type { EndpointContext } from '../../core/transport.js' +import type { GrowthSourcesOptions, GrowthSourcesResponse, GrowthSourceItem } from '../../core/types.js' + +function growthSourcesQuery(options: GrowthSourcesOptions): URLSearchParams { + const params = new URLSearchParams() + params.set('order_by', options.orderBy ?? 'users') + params.set('order_direction', options.orderDirection ?? 'desc') + if (options.fromDate) params.set('from_date', options.fromDate) + if (options.toDate) params.set('to_date', options.toDate) + return params +} + +/** + * Returns historical breakdown of traffic, subscriber growth, and revenue by acquisition source. + */ +export function getGrowthSources( + context: EndpointContext, + options: GrowthSourcesOptions = {} +): Promise> { + const query = growthSourcesQuery(options).toString() + return context.publication(`/publication/stats/growth/sources${query ? `?${query}` : ''}`) +} diff --git a/src/resources/subscriber-stats/index.ts b/src/resources/subscriber-stats/index.ts index cb0bab2..fa552c5 100644 --- a/src/resources/subscriber-stats/index.ts +++ b/src/resources/subscriber-stats/index.ts @@ -1,12 +1,37 @@ import type { EndpointContext } from '../../core/transport.js' -import type { SubscriberStatsResponse } from '../../core/types.js' +import type { SubscriberStatsResponse, EmailStatsItem } from '../../core/types.js' -/** - * Returns the publication's subscriber records and aggregate subscriber count. - * The response can contain subscriber personal data, including email addresses. - */ -export function getSubscriberStats( +export async function getSubscriberStats( context: EndpointContext -): Promise> { - return context.publication('/subscriber-stats') +): Promise | Record> { + try { + // 1. Try legacy/native endpoint + return await context.publication>('/subscriber-stats') + } catch (error: any) { + // 2. If Substack returns 404 (endpoint deprecated), fall back to email-stats + if (error?.status === 404 || error?.message?.includes('404')) { + let emailStats: any + try { + emailStats = await context.publication('/email-stats') + } catch (err: any) { + if (err?.status === 404 || err?.message?.includes('404')) { + const res = await context.publication<{ rows?: EmailStatsItem[] }>( + '/publication/stats/email_stats?offset=0&limit=20' + ) + emailStats = res?.rows ?? [] + } else { + throw err + } + } + const latest = Array.isArray(emailStats) && emailStats.length > 0 ? emailStats[0] : null + return { + derived_from_delivery: true, + active_subscribers_delivered: latest?.delivered ?? latest?.queued ?? 0, + recent_signups: latest?.signups ?? 0, + latest_post_title: latest?.title ?? '', + open_rate: latest?.open_rate ? `${(latest.open_rate * 100).toFixed(1)}%` : '0%' + } + } + throw error + } } diff --git a/test/client.test.ts b/test/client.test.ts index 8918396..18e8dca 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -604,6 +604,95 @@ describe('SubstackClient', () => { expect(request?.headers.get('cookie')).toBe('substack.sid=session-value') }) + test('falls back to email-stats when subscriber-stats returns 404', async () => { + const requests: string[] = [] + const client = new SubstackClient({ + sessionToken: 'session-value', + publicationUrl: 'https://allagentsconsidered.substack.com', + fetch: async (input) => { + const url = new Request(input).url + requests.push(url) + if (url.includes('/subscriber-stats')) { + return new Response('Not Found', { status: 404 }) + } + if (url.includes('/email-stats')) { + return Response.json([ + { + delivered: 1500, + signups: 12, + title: 'Latest Post Title', + open_rate: 0.452 + } + ]) + } + return new Response('Not Found', { status: 404 }) + } + }) + + const result = await client.getSubscriberStats() + expect(result).toEqual({ + derived_from_delivery: true, + active_subscribers_delivered: 1500, + recent_signups: 12, + latest_post_title: 'Latest Post Title', + open_rate: '45.2%' + }) + expect(requests).toEqual([ + 'https://allagentsconsidered.substack.com/api/v1/subscriber-stats', + 'https://allagentsconsidered.substack.com/api/v1/email-stats' + ]) + }) + + test('re-throws non-404 errors when getting subscriber stats', async () => { + const client = new SubstackClient({ + sessionToken: 'session-value', + publicationUrl: 'https://allagentsconsidered.substack.com', + fetch: async () => new Response('Internal Server Error', { status: 500 }) + }) + + await expect(client.getSubscriberStats()).rejects.toThrow(SubstackApiError) + }) + + test('gets publication growth sources with query options', async () => { + const requests: Request[] = [] + const fakeResponse = { + sourceMetrics: [ + { + source: 'substack', + sourceName: 'Substack', + metrics: [{ name: 'Traffic', total: 149 }] + } + ], + totals: [{ name: 'traffic', total: 149 }] + } + const client = new SubstackClient({ + sessionToken: 'session-value', + publicationUrl: 'https://allagentsconsidered.substack.com', + fetch: async (input, init) => { + const request = new Request(input, init) + requests.push(request) + return Response.json(fakeResponse) + } + }) + + const result = await client.getGrowthSources({ + fromDate: '2026-07-29', + toDate: '2026-08-27', + orderBy: 'users', + orderDirection: 'desc' + }) + + expect(result).toEqual(fakeResponse) + expect(requests[0].url).toBe( + 'https://allagentsconsidered.substack.com/api/v1/publication/stats/growth/sources?order_by=users&order_direction=desc&from_date=2026-07-29&to_date=2026-08-27' + ) + }) + + test('requires a publication URL for growth sources', () => { + const client = new SubstackClient({ sessionToken: 'session-value' }) + expect(() => client.getGrowthSources()).toThrow(SubstackConfigurationError) + }) + test('creates link attachments and publishes Notes through global write endpoints', async () => { const calls: Array<{ method: string; url: string; body: unknown }> = [] const client = new SubstackClient({ diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 2713d0f..f15b0df 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -79,6 +79,10 @@ const mockClient = (overrides: Record = {}) => ({ activityItems: [{ id: 1 }, { id: 2 }], unread: { count: 2, strategy: 'latest-activity-items' } }), + getGrowthSources: async () => ({ + sourceMetrics: [{ source: 'substack', sourceName: 'Substack' }], + totals: [{ name: 'traffic', total: 149 }] + }), ...overrides }) @@ -256,6 +260,58 @@ describe('MCP tools', () => { }) }) + test('returns clean structured content and text for subscriber stats', async () => { + const tools = createToolHandlers(mockClient() as never) + const result = await tools.getSubscriberStats() + + expect(result.isError).toBeUndefined() + expect(result.structuredContent).toMatchObject({ total: 2 }) + expect(result.content[0].type).toBe('text') + expect(JSON.parse(result.content[0].text)).toMatchObject({ total: 2 }) + }) + + test('handles errors cleanly in getSubscriberStats tool', async () => { + const tools = createToolHandlers( + mockClient({ + getSubscriberStats: async () => { + throw new Error('Network timeout') + } + }) as never + ) + const result = await tools.getSubscriberStats() + + expect(result.isError).toBe(true) + expect(result.content[0].text).toBe('Failed to fetch subscriber stats: Network timeout') + }) + + test('returns growth sources structured content from tool handler', async () => { + const tools = createToolHandlers(mockClient() as never) + const result = await tools.getGrowthSources({ + fromDate: '2026-07-29', + toDate: '2026-08-27', + orderBy: 'users' + }) + + expect(result.isError).toBeUndefined() + expect(result.structuredContent).toEqual({ + data: { + sourceMetrics: [{ source: 'substack', sourceName: 'Substack' }], + totals: [{ name: 'traffic', total: 149 }] + } + }) + }) + + test('rejects an inverted growth sources date range', async () => { + const tools = createToolHandlers(mockClient() as never) + const result = await tools.getGrowthSources({ + fromDate: '2026-08-27', + toDate: '2026-07-29' + }) + + expect(result.isError).toBe(true) + expect(result.content[0].text).toContain('fromDate cannot be after toDate') + }) + test('caps activity while retaining unread metadata', async () => { const tools = createToolHandlers(mockClient() as never)