-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.ts
More file actions
142 lines (129 loc) · 4.34 KB
/
Copy pathclient.ts
File metadata and controls
142 lines (129 loc) · 4.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/**
* Minimal typed Substack client. Drop-in for any Node 18+ runtime (fetch is
* built-in). For browser use, wrap calls in your own CORS proxy — Substack
* doesn't return permissive CORS headers.
*
* Usage:
* const client = new Substack('s%3A...your.cookie.value');
* const me = await client.me();
* const pub = me.publicationUsers.find(p => p.is_primary)!.publication;
* const draft = await client.createDraft(pub.subdomain, {
* title: 'Hello', subtitle: 'World', html: '<p>...</p>'
* });
* await client.publishDraft(pub.subdomain, draft.id);
*/
export interface SubstackPublication {
id: number;
name: string;
subdomain: string;
logo_url?: string;
}
export interface SubstackPublicationUser {
publication: SubstackPublication;
role: string; // 'admin', 'editor', etc.
is_primary: boolean;
}
export interface SubstackProfile {
id: number;
name: string;
handle: string;
bio?: string;
photo_url?: string;
publicationUsers: SubstackPublicationUser[];
}
export interface SubstackDraft {
id: number;
draft_title: string;
draft_subtitle: string;
slug: string;
}
export class Substack {
constructor(private readonly cookie: string) {
if (!cookie) throw new Error('Substack: cookie is required');
}
private headers(extra: Record<string, string> = {}): Record<string, string> {
return {
Cookie: `connect.sid=${this.cookie}; substack.sid=${this.cookie}`,
'User-Agent': 'Mozilla/5.0',
Accept: 'application/json',
...extra,
};
}
private async req<T>(host: string, path: string, init?: RequestInit): Promise<T> {
const url = `https://${host}${path}`;
const res = await fetch(url, {
...init,
headers: this.headers(init?.body ? { 'Content-Type': 'application/json' } : {}),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Substack ${init?.method || 'GET'} ${path}: ${res.status} ${body.slice(0, 300)}`);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
/** GET your profile + the list of publications you can post to. */
async me(): Promise<SubstackProfile> {
return this.req<SubstackProfile>('substack.com', '/api/v1/user/profile/self');
}
/** GET drafts on a publication — also a permission check (403 if not admin). */
async listDrafts(subdomain: string, limit = 25): Promise<SubstackDraft[]> {
return this.req<SubstackDraft[]>(`${subdomain}.substack.com`, `/api/v1/drafts?limit=${limit}`);
}
/** Create a draft. Returns the draft's id + slug. */
async createDraft(
subdomain: string,
opts: { title: string; subtitle?: string; html: string },
): Promise<SubstackDraft> {
return this.req<SubstackDraft>(`${subdomain}.substack.com`, '/api/v1/drafts', {
method: 'POST',
body: JSON.stringify({
draft_title: opts.title,
draft_subtitle: opts.subtitle || '',
draft_body: opts.html,
type: 'newsletter',
}),
});
}
/** Update an existing draft (preserves draft URL). */
async updateDraft(
subdomain: string,
draftId: number,
opts: { title: string; subtitle?: string; html: string },
): Promise<void> {
await this.req(`${subdomain}.substack.com`, `/api/v1/drafts/${draftId}`, {
method: 'PUT',
body: JSON.stringify({
draft_title: opts.title,
draft_subtitle: opts.subtitle || '',
draft_body: opts.html,
}),
});
}
/** Delete a draft. 404 (already gone) is treated as success. */
async deleteDraft(subdomain: string, draftId: number): Promise<void> {
try {
await this.req(`${subdomain}.substack.com`, `/api/v1/drafts/${draftId}`, { method: 'DELETE' });
} catch (err) {
if (err instanceof Error && /\b404\b/.test(err.message)) return;
throw err;
}
}
/**
* Publish a draft. send=true emails subscribers — IRREVERSIBLE.
* send=false publishes to web only.
*/
async publishDraft(
subdomain: string,
draftId: number,
opts: { send: boolean; shareAutomatically?: boolean } = { send: true },
): Promise<{ slug: string; post_date: string }> {
return this.req(`${subdomain}.substack.com`, `/api/v1/drafts/${draftId}/publish`, {
method: 'PUT',
body: JSON.stringify({
send: opts.send,
share_automatically: opts.shareAutomatically ?? false,
}),
});
}
}