@destink/substack-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,929 @@
1
+ 'use strict';
2
+
3
+ var initCycleTLS = require('cycletls');
4
+ var t = require('io-ts');
5
+ var _function = require('fp-ts/function');
6
+ var Either = require('fp-ts/Either');
7
+ var PathReporter = require('io-ts/PathReporter');
8
+
9
+ function _interopNamespaceDefault(e) {
10
+ var n = Object.create(null);
11
+ if (e) {
12
+ Object.keys(e).forEach(function (k) {
13
+ if (k !== 'default') {
14
+ var d = Object.getOwnPropertyDescriptor(e, k);
15
+ Object.defineProperty(n, k, d.get ? d : {
16
+ enumerable: true,
17
+ get: function () { return e[k]; }
18
+ });
19
+ }
20
+ });
21
+ }
22
+ n.default = e;
23
+ return Object.freeze(n);
24
+ }
25
+
26
+ var t__namespace = /*#__PURE__*/_interopNamespaceDefault(t);
27
+
28
+ /**
29
+ * HTTP client for direct Substack API access via CycleTLS
30
+ *
31
+ * Uses CycleTLS to bypass Cloudflare bot detection by spoofing browser
32
+ * TLS/JA3 fingerprints. Authentication is via session cookies sent directly
33
+ * to Substack — no third-party proxy involved.
34
+ */
35
+ const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
36
+ class HttpClient {
37
+ constructor(config) {
38
+ this.client = null;
39
+ this.lastRequestTime = 0;
40
+ this.headers = {
41
+ 'Content-Type': 'application/json',
42
+ Cookie: `substack.sid=${config.substackSid}; substack.lli=${config.substackLli}`
43
+ };
44
+ this.publicationBaseUrl = config.publicationUrl.replace(/\/$/, '');
45
+ this.minInterval = 1000 / (config.maxRequestsPerSecond || 25);
46
+ }
47
+ async getClient() {
48
+ if (!this.client) {
49
+ this.client = await initCycleTLS();
50
+ }
51
+ return this.client;
52
+ }
53
+ async throttle() {
54
+ const now = Date.now();
55
+ const elapsed = now - this.lastRequestTime;
56
+ if (elapsed < this.minInterval) {
57
+ await new Promise((r) => setTimeout(r, this.minInterval - elapsed));
58
+ }
59
+ this.lastRequestTime = Date.now();
60
+ }
61
+ resolveBaseUrl(scope) {
62
+ if (scope === 'global')
63
+ return 'https://substack.com';
64
+ if (scope === 'publication')
65
+ return this.publicationBaseUrl;
66
+ return `https://${scope.subdomain}.substack.com`;
67
+ }
68
+ buildUrl(path, scope, params) {
69
+ const base = this.resolveBaseUrl(scope);
70
+ let url = `${base}${path}`;
71
+ if (params) {
72
+ const searchParams = new URLSearchParams();
73
+ for (const [key, value] of Object.entries(params)) {
74
+ if (value !== undefined) {
75
+ searchParams.set(key, String(value));
76
+ }
77
+ }
78
+ const qs = searchParams.toString();
79
+ if (qs)
80
+ url += `?${qs}`;
81
+ }
82
+ return url;
83
+ }
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
85
+ parseResponseData(data) {
86
+ if (typeof data === 'string') {
87
+ try {
88
+ return JSON.parse(data);
89
+ }
90
+ catch (_a) {
91
+ return data;
92
+ }
93
+ }
94
+ return data;
95
+ }
96
+ async get(path, params, scope = 'global') {
97
+ await this.throttle();
98
+ const url = this.buildUrl(path, scope, params);
99
+ const client = await this.getClient();
100
+ const response = await client(url, { headers: this.headers, userAgent: USER_AGENT }, 'get');
101
+ if (response.status === 401 || response.status === 403) {
102
+ throw new Error(`Authentication failed (${response.status}). Your session cookies may have expired.`);
103
+ }
104
+ if (response.status < 200 || response.status >= 300) {
105
+ throw new Error(`HTTP ${response.status} for GET ${path}`);
106
+ }
107
+ return this.parseResponseData(response.data);
108
+ }
109
+ async post(path, data, scope = 'global') {
110
+ await this.throttle();
111
+ const url = this.buildUrl(path, scope);
112
+ const client = await this.getClient();
113
+ const response = await client(url, {
114
+ headers: this.headers,
115
+ userAgent: USER_AGENT,
116
+ ...(data !== undefined ? { body: JSON.stringify(data) } : {})
117
+ }, 'post');
118
+ if (response.status === 401 || response.status === 403) {
119
+ throw new Error(`Authentication failed (${response.status}). Your session cookies may have expired.`);
120
+ }
121
+ if (response.status < 200 || response.status >= 300) {
122
+ throw new Error(`HTTP ${response.status} for POST ${path}`);
123
+ }
124
+ return this.parseResponseData(response.data);
125
+ }
126
+ async put(path, data, scope = 'global') {
127
+ await this.throttle();
128
+ const url = this.buildUrl(path, scope);
129
+ const client = await this.getClient();
130
+ const response = await client(url, {
131
+ headers: this.headers,
132
+ userAgent: USER_AGENT,
133
+ ...(data !== undefined ? { body: JSON.stringify(data) } : {})
134
+ }, 'put');
135
+ if (response.status === 401 || response.status === 403) {
136
+ throw new Error(`Authentication failed (${response.status}). Your session cookies may have expired.`);
137
+ }
138
+ if (response.status < 200 || response.status >= 300) {
139
+ throw new Error(`HTTP ${response.status} for PUT ${path}`);
140
+ }
141
+ return this.parseResponseData(response.data);
142
+ }
143
+ async delete(path, scope = 'global') {
144
+ await this.throttle();
145
+ const url = this.buildUrl(path, scope);
146
+ const client = await this.getClient();
147
+ const response = await client(url, { headers: this.headers, userAgent: USER_AGENT }, 'delete');
148
+ if (response.status < 200 || response.status >= 300) {
149
+ throw new Error(`HTTP ${response.status} for DELETE ${path}`);
150
+ }
151
+ }
152
+ async close() {
153
+ if (this.client) {
154
+ await this.client.exit();
155
+ this.client = null;
156
+ }
157
+ }
158
+ }
159
+
160
+ class Comment {
161
+ constructor(rawData) {
162
+ var _a, _b;
163
+ this.rawData = rawData;
164
+ this.id = rawData.id;
165
+ this.body = rawData.body;
166
+ this.date = rawData.date;
167
+ this.authorName = (_a = rawData.name) !== null && _a !== void 0 ? _a : undefined;
168
+ this.authorHandle = (_b = rawData.handle) !== null && _b !== void 0 ? _b : undefined;
169
+ this.reactionCount = rawData.reaction_count;
170
+ }
171
+ }
172
+
173
+ class PreviewPost {
174
+ constructor(rawData, commentService, postService, publicationSubdomain) {
175
+ this.commentService = commentService;
176
+ this.postService = postService;
177
+ this.id = rawData.id;
178
+ this.title = rawData.title;
179
+ this.slug = rawData.slug;
180
+ this.subtitle = rawData.subtitle || '';
181
+ this.truncatedBody = rawData.truncated_body_text || '';
182
+ this.body = rawData.body_html || rawData.truncated_body_text || '';
183
+ this.publishedAt = new Date(rawData.post_date);
184
+ this.publicationSubdomain = publicationSubdomain;
185
+ }
186
+ async fullPost() {
187
+ try {
188
+ const fullPostData = await this.postService.getPostBySlug(this.slug, this.publicationSubdomain);
189
+ return new FullPost(fullPostData, this.commentService, this.publicationSubdomain);
190
+ }
191
+ catch (error) {
192
+ throw new Error(`Failed to fetch full post ${this.slug}: ${error.message}`);
193
+ }
194
+ }
195
+ async *comments(options = {}) {
196
+ try {
197
+ const commentsData = await this.commentService.getCommentsForPost(this.id, this.publicationSubdomain);
198
+ let count = 0;
199
+ for (const commentData of commentsData) {
200
+ if (options.limit && count >= options.limit)
201
+ break;
202
+ yield new Comment(commentData);
203
+ count++;
204
+ }
205
+ }
206
+ catch (error) {
207
+ throw new Error(`Failed to get comments for post ${this.id}: ${error.message}`);
208
+ }
209
+ }
210
+ async like() {
211
+ throw new Error('Post liking not implemented yet - requires like API');
212
+ }
213
+ async addComment(_data) {
214
+ throw new Error('Comment creation not implemented yet - requires comment creation API');
215
+ }
216
+ }
217
+ class FullPost {
218
+ constructor(rawData, commentService, publicationSubdomain) {
219
+ var _a, _b, _c;
220
+ this.commentService = commentService;
221
+ this.publicationSubdomain = publicationSubdomain;
222
+ this.id = rawData.id;
223
+ this.title = rawData.title;
224
+ this.subtitle = rawData.subtitle || '';
225
+ this.truncatedBody = rawData.truncated_body_text || '';
226
+ this.body = rawData.body_html || rawData.truncated_body_text || '';
227
+ this.publishedAt = new Date(rawData.post_date);
228
+ this.url = rawData.canonical_url || '';
229
+ this.htmlBody = rawData.body_html || '';
230
+ this.slug = rawData.slug;
231
+ this.createdAt = new Date(rawData.post_date);
232
+ this.reactions = (_a = rawData.reactions) !== null && _a !== void 0 ? _a : undefined;
233
+ this.restacks = (_b = rawData.restacks) !== null && _b !== void 0 ? _b : undefined;
234
+ this.coverImage = (_c = rawData.cover_image) !== null && _c !== void 0 ? _c : undefined;
235
+ }
236
+ async *comments(options = {}) {
237
+ try {
238
+ const commentsData = await this.commentService.getCommentsForPost(this.id, this.publicationSubdomain);
239
+ let count = 0;
240
+ for (const commentData of commentsData) {
241
+ if (options.limit && count >= options.limit)
242
+ break;
243
+ yield new Comment(commentData);
244
+ count++;
245
+ }
246
+ }
247
+ catch (error) {
248
+ throw new Error(`Failed to get comments for post ${this.id}: ${error.message}`);
249
+ }
250
+ }
251
+ async like() {
252
+ throw new Error('Post liking not implemented yet - requires like API');
253
+ }
254
+ async addComment(_data) {
255
+ throw new Error('Comment creation not implemented yet - requires comment creation API');
256
+ }
257
+ }
258
+
259
+ class Note {
260
+ constructor(rawData) {
261
+ var _a;
262
+ this.rawData = rawData;
263
+ this.id = rawData.id;
264
+ this.body = rawData.body;
265
+ this.likesCount = (_a = rawData.reaction_count) !== null && _a !== void 0 ? _a : 0;
266
+ this.publishedAt = new Date(rawData.date);
267
+ this.author = {
268
+ id: rawData.user_id,
269
+ name: rawData.name || '',
270
+ handle: rawData.handle || '',
271
+ avatarUrl: rawData.photo_url || ''
272
+ };
273
+ }
274
+ }
275
+
276
+ class Profile {
277
+ constructor(rawData, postService, noteService, commentService, perPage) {
278
+ var _a;
279
+ this.rawData = rawData;
280
+ this.postService = postService;
281
+ this.noteService = noteService;
282
+ this.commentService = commentService;
283
+ this.perPage = perPage;
284
+ this.id = rawData.id;
285
+ this.slug = rawData.handle;
286
+ this.handle = rawData.handle;
287
+ this.name = rawData.name;
288
+ this.url = `https://substack.com/@${rawData.handle}`;
289
+ this.avatarUrl = rawData.photo_url;
290
+ this.bio = (_a = rawData.bio) !== null && _a !== void 0 ? _a : undefined;
291
+ }
292
+ async *posts(options = {}) {
293
+ var _a;
294
+ try {
295
+ let cursor = undefined;
296
+ let totalYielded = 0;
297
+ while (true) {
298
+ const result = await this.postService.getPostsForProfile(this.id, { cursor });
299
+ for (const feedItem of result.posts) {
300
+ if (options.limit && totalYielded >= options.limit) {
301
+ return;
302
+ }
303
+ if (feedItem.post) {
304
+ const subdomain = (_a = feedItem.publication) === null || _a === void 0 ? void 0 : _a.subdomain;
305
+ yield new PreviewPost(feedItem.post, this.commentService, this.postService, subdomain);
306
+ totalYielded++;
307
+ }
308
+ }
309
+ if (!result.nextCursor || result.posts.length === 0) {
310
+ break;
311
+ }
312
+ cursor = result.nextCursor;
313
+ }
314
+ }
315
+ catch (_b) {
316
+ yield* [];
317
+ }
318
+ }
319
+ async *notes(options = {}) {
320
+ try {
321
+ let cursor = undefined;
322
+ let totalYielded = 0;
323
+ while (true) {
324
+ const paginatedNotes = await this.noteService.getNotesForProfile(this.id, { cursor });
325
+ for (const item of paginatedNotes.notes) {
326
+ if (options.limit && totalYielded >= options.limit) {
327
+ return;
328
+ }
329
+ yield new Note(item);
330
+ totalYielded++;
331
+ }
332
+ if (!paginatedNotes.nextCursor) {
333
+ break;
334
+ }
335
+ cursor = paginatedNotes.nextCursor;
336
+ }
337
+ }
338
+ catch (_a) {
339
+ yield* [];
340
+ }
341
+ }
342
+ }
343
+
344
+ class OwnProfile extends Profile {
345
+ constructor(rawData, postService, noteService, commentService, profileService, followingService, newNoteService, perPage) {
346
+ super(rawData, postService, noteService, commentService, perPage);
347
+ this.profileService = profileService;
348
+ this.followingService = followingService;
349
+ this.newNoteService = newNoteService;
350
+ }
351
+ async publishNote(content, options) {
352
+ return this.newNoteService.publishNote(content, options === null || options === void 0 ? void 0 : options.attachmentIds);
353
+ }
354
+ async *following(options = {}) {
355
+ const followingUsers = await this.followingService.getFollowing();
356
+ let count = 0;
357
+ for (const user of followingUsers) {
358
+ if (options.limit && count >= options.limit)
359
+ break;
360
+ try {
361
+ const profileData = await this.profileService.getProfileBySlug(user.handle);
362
+ yield new Profile(profileData, this.postService, this.noteService, this.commentService, this.perPage);
363
+ count++;
364
+ }
365
+ catch (_a) {
366
+ /* empty */
367
+ }
368
+ }
369
+ }
370
+ async *notes(options = {}) {
371
+ try {
372
+ let cursor = undefined;
373
+ let totalYielded = 0;
374
+ while (true) {
375
+ const paginatedNotes = await this.noteService.getNotesForProfile(this.id, { cursor });
376
+ for (const noteData of paginatedNotes.notes) {
377
+ if (options.limit && totalYielded >= options.limit) {
378
+ return;
379
+ }
380
+ yield new Note(noteData);
381
+ totalYielded++;
382
+ }
383
+ if (!paginatedNotes.nextCursor) {
384
+ break;
385
+ }
386
+ cursor = paginatedNotes.nextCursor;
387
+ }
388
+ }
389
+ catch (_a) {
390
+ yield* [];
391
+ }
392
+ }
393
+ }
394
+
395
+ /**
396
+ * io-ts codecs and inferred types for Substack API response shapes.
397
+ *
398
+ * These match the real Substack API responses (not the gateway proxy).
399
+ * Fields are validated loosely — only the fields used by domain entities
400
+ * are required; extra fields from Substack are ignored by io-ts.
401
+ */
402
+ // ------------------------------------------------------------------
403
+ // Profile
404
+ // ------------------------------------------------------------------
405
+ const SubstackProfileC = t__namespace.intersection([
406
+ t__namespace.type({
407
+ id: t__namespace.number,
408
+ handle: t__namespace.string,
409
+ name: t__namespace.string,
410
+ photo_url: t__namespace.string
411
+ }),
412
+ t__namespace.partial({
413
+ bio: t__namespace.union([t__namespace.string, t__namespace.null])
414
+ })
415
+ ]);
416
+ // ------------------------------------------------------------------
417
+ // Feed envelope (shared by posts, notes, following feeds)
418
+ // ------------------------------------------------------------------
419
+ const SubstackFeedItemPostC = t__namespace.intersection([
420
+ t__namespace.type({
421
+ id: t__namespace.number,
422
+ title: t__namespace.string,
423
+ slug: t__namespace.string,
424
+ post_date: t__namespace.string
425
+ }),
426
+ t__namespace.partial({
427
+ subtitle: t__namespace.union([t__namespace.string, t__namespace.null]),
428
+ truncated_body_text: t__namespace.union([t__namespace.string, t__namespace.null]),
429
+ body_html: t__namespace.union([t__namespace.string, t__namespace.null]),
430
+ canonical_url: t__namespace.union([t__namespace.string, t__namespace.null]),
431
+ reactions: t__namespace.union([t__namespace.record(t__namespace.string, t__namespace.number), t__namespace.null]),
432
+ restacks: t__namespace.union([t__namespace.number, t__namespace.null]),
433
+ cover_image: t__namespace.union([t__namespace.string, t__namespace.null]),
434
+ publication_id: t__namespace.number,
435
+ comment_count: t__namespace.number,
436
+ type: t__namespace.string
437
+ })
438
+ ]);
439
+ const SubstackFeedPublicationC = t__namespace.intersection([
440
+ t__namespace.type({
441
+ id: t__namespace.number,
442
+ subdomain: t__namespace.string,
443
+ name: t__namespace.string
444
+ }),
445
+ t__namespace.partial({
446
+ custom_domain: t__namespace.union([t__namespace.string, t__namespace.null]),
447
+ logo_url: t__namespace.union([t__namespace.string, t__namespace.null]),
448
+ author_id: t__namespace.number
449
+ })
450
+ ]);
451
+ const SubstackFeedCommentC = t__namespace.intersection([
452
+ t__namespace.type({
453
+ id: t__namespace.number,
454
+ body: t__namespace.string,
455
+ user_id: t__namespace.number,
456
+ date: t__namespace.string
457
+ }),
458
+ t__namespace.partial({
459
+ name: t__namespace.union([t__namespace.string, t__namespace.null]),
460
+ handle: t__namespace.union([t__namespace.string, t__namespace.null]),
461
+ photo_url: t__namespace.union([t__namespace.string, t__namespace.null]),
462
+ body_json: t__namespace.unknown,
463
+ publication_id: t__namespace.union([t__namespace.number, t__namespace.null]),
464
+ post_id: t__namespace.union([t__namespace.number, t__namespace.null]),
465
+ type: t__namespace.string,
466
+ reaction_count: t__namespace.number,
467
+ reactions: t__namespace.union([t__namespace.record(t__namespace.string, t__namespace.number), t__namespace.null]),
468
+ restacks: t__namespace.number,
469
+ children_count: t__namespace.number,
470
+ attachments: t__namespace.array(t__namespace.unknown),
471
+ ancestor_path: t__namespace.string,
472
+ edited_at: t__namespace.union([t__namespace.string, t__namespace.null]),
473
+ reply_minimum_role: t__namespace.union([t__namespace.string, t__namespace.null])
474
+ })
475
+ ]);
476
+ const SubstackFeedItemC = t__namespace.intersection([
477
+ t__namespace.type({
478
+ entity_key: t__namespace.string,
479
+ type: t__namespace.string
480
+ }),
481
+ t__namespace.partial({
482
+ publication: t__namespace.union([SubstackFeedPublicationC, t__namespace.null]),
483
+ post: t__namespace.union([SubstackFeedItemPostC, t__namespace.null]),
484
+ comment: t__namespace.union([SubstackFeedCommentC, t__namespace.null])
485
+ })
486
+ ]);
487
+ const SubstackFeedPageC = t__namespace.intersection([
488
+ t__namespace.type({
489
+ items: t__namespace.array(SubstackFeedItemC)
490
+ }),
491
+ t__namespace.partial({
492
+ nextCursor: t__namespace.union([t__namespace.string, t__namespace.null]),
493
+ originalCursorTimestamp: t__namespace.union([t__namespace.string, t__namespace.null])
494
+ })
495
+ ]);
496
+ // ------------------------------------------------------------------
497
+ // Full post (from GET /api/v1/posts/{slug})
498
+ // ------------------------------------------------------------------
499
+ const SubstackFullPostC = t__namespace.intersection([
500
+ t__namespace.type({
501
+ id: t__namespace.number,
502
+ title: t__namespace.string,
503
+ slug: t__namespace.string,
504
+ post_date: t__namespace.string
505
+ }),
506
+ t__namespace.partial({
507
+ subtitle: t__namespace.union([t__namespace.string, t__namespace.null]),
508
+ body_html: t__namespace.union([t__namespace.string, t__namespace.null]),
509
+ truncated_body_text: t__namespace.union([t__namespace.string, t__namespace.null]),
510
+ canonical_url: t__namespace.union([t__namespace.string, t__namespace.null]),
511
+ reactions: t__namespace.union([t__namespace.record(t__namespace.string, t__namespace.number), t__namespace.null]),
512
+ restacks: t__namespace.union([t__namespace.number, t__namespace.null]),
513
+ cover_image: t__namespace.union([t__namespace.string, t__namespace.null]),
514
+ publication_id: t__namespace.number,
515
+ type: t__namespace.string,
516
+ is_published: t__namespace.boolean,
517
+ comment_count: t__namespace.number
518
+ })
519
+ ]);
520
+ // ------------------------------------------------------------------
521
+ // Comments (from GET /api/v1/post/{id}/comments)
522
+ // ------------------------------------------------------------------
523
+ const SubstackCommentC = t__namespace.recursion('SubstackComment', () => t__namespace.intersection([
524
+ t__namespace.type({
525
+ id: t__namespace.number,
526
+ body: t__namespace.string
527
+ }),
528
+ t__namespace.partial({
529
+ body_json: t__namespace.unknown,
530
+ publication_id: t__namespace.union([t__namespace.number, t__namespace.null]),
531
+ post_id: t__namespace.union([t__namespace.number, t__namespace.null]),
532
+ user_id: t__namespace.number,
533
+ ancestor_path: t__namespace.string,
534
+ type: t__namespace.string,
535
+ date: t__namespace.string,
536
+ name: t__namespace.union([t__namespace.string, t__namespace.null]),
537
+ handle: t__namespace.union([t__namespace.string, t__namespace.null]),
538
+ photo_url: t__namespace.union([t__namespace.string, t__namespace.null]),
539
+ reaction_count: t__namespace.number,
540
+ reactions: t__namespace.union([t__namespace.record(t__namespace.string, t__namespace.number), t__namespace.null]),
541
+ restacks: t__namespace.number,
542
+ deleted: t__namespace.boolean,
543
+ children: t__namespace.array(SubstackCommentC)
544
+ })
545
+ ]));
546
+ const SubstackCommentsResponseC = t__namespace.type({
547
+ comments: t__namespace.array(SubstackCommentC)
548
+ });
549
+ // ------------------------------------------------------------------
550
+ // Subscriptions / Following (from GET /api/v1/subscriptions/page)
551
+ // ------------------------------------------------------------------
552
+ const SubscriptionPublicationAuthorC = t__namespace.intersection([
553
+ t__namespace.type({
554
+ id: t__namespace.number,
555
+ handle: t__namespace.string
556
+ }),
557
+ t__namespace.partial({
558
+ name: t__namespace.union([t__namespace.string, t__namespace.null]),
559
+ photo_url: t__namespace.union([t__namespace.string, t__namespace.null])
560
+ })
561
+ ]);
562
+ const SubscriptionPublicationC = t__namespace.intersection([
563
+ t__namespace.type({
564
+ id: t__namespace.number,
565
+ subdomain: t__namespace.string,
566
+ name: t__namespace.string
567
+ }),
568
+ t__namespace.partial({
569
+ author: SubscriptionPublicationAuthorC
570
+ })
571
+ ]);
572
+ const SubscriptionC = t__namespace.intersection([
573
+ t__namespace.type({
574
+ id: t__namespace.number,
575
+ publication_id: t__namespace.number
576
+ }),
577
+ t__namespace.partial({
578
+ membership_state: t__namespace.string,
579
+ publication: SubscriptionPublicationC
580
+ })
581
+ ]);
582
+ const SubstackSubscriptionsResponseC = t__namespace.type({
583
+ subscriptions: t__namespace.array(SubscriptionC)
584
+ });
585
+ // ------------------------------------------------------------------
586
+ // Create note response (from POST /api/v1/comment/feed)
587
+ // ------------------------------------------------------------------
588
+ const SubstackCreateNoteResponseC = t__namespace.type({ id: t__namespace.number });
589
+
590
+ /**
591
+ * Utility functions for runtime validation using io-ts and fp-ts
592
+ */
593
+ /**
594
+ * Decode and validate data using an io-ts codec
595
+ * @param codec - The io-ts codec to use for validation
596
+ * @param data - The raw data to validate
597
+ * @param errorContext - Context information for error messages
598
+ * @returns The validated data
599
+ * @throws {Error} If validation fails
600
+ */
601
+ function decodeOrThrow(codec, data, errorContext) {
602
+ const result = codec.decode(data);
603
+ return _function.pipe(result, Either.fold((_errors) => {
604
+ const errorMessage = PathReporter.PathReporter.report(result).join(', ');
605
+ console.log(`Invalid ${errorContext}: ${errorMessage}`);
606
+ throw new Error(`Invalid ${errorContext}: ${errorMessage}`);
607
+ }, (parsed) => parsed));
608
+ }
609
+
610
+ class PostService {
611
+ constructor(client) {
612
+ this.client = client;
613
+ }
614
+ async getPostBySlug(slug, subdomain) {
615
+ const scope = subdomain ? { subdomain } : 'publication';
616
+ const raw = await this.client.get(`/api/v1/posts/${encodeURIComponent(slug)}`, undefined, scope);
617
+ return decodeOrThrow(SubstackFullPostC, raw, 'SubstackFullPost');
618
+ }
619
+ async getPostsForProfile(userId, options) {
620
+ const params = {};
621
+ if (options === null || options === void 0 ? void 0 : options.cursor)
622
+ params.cursor = options.cursor;
623
+ const raw = await this.client.get(`/api/v1/reader/feed/profile/${userId}`, params);
624
+ const page = decodeOrThrow(SubstackFeedPageC, raw, 'SubstackFeedPage');
625
+ const postItems = page.items.filter((item) => item.type === 'post');
626
+ return { posts: postItems, nextCursor: page.nextCursor };
627
+ }
628
+ }
629
+
630
+ class NoteService {
631
+ constructor(client) {
632
+ this.client = client;
633
+ }
634
+ async getNotesForProfile(userId, options) {
635
+ const params = { filter: 'comment' };
636
+ if (options === null || options === void 0 ? void 0 : options.cursor)
637
+ params.cursor = options.cursor;
638
+ const raw = await this.client.get(`/api/v1/reader/feed/profile/${userId}`, params);
639
+ const page = decodeOrThrow(SubstackFeedPageC, raw, 'SubstackFeedPage');
640
+ const notes = page.items
641
+ .filter((item) => item.type === 'comment' && item.comment)
642
+ .map((item) => item.comment);
643
+ return { notes, nextCursor: page.nextCursor };
644
+ }
645
+ }
646
+
647
+ class ProfileService {
648
+ constructor(client) {
649
+ this.client = client;
650
+ }
651
+ async getOwnProfile(handle) {
652
+ const raw = await this.client.get(`/api/v1/user/${encodeURIComponent(handle)}/public_profile`);
653
+ return decodeOrThrow(SubstackProfileC, raw, 'SubstackProfile');
654
+ }
655
+ async getProfileBySlug(handle) {
656
+ const raw = await this.client.get(`/api/v1/user/${encodeURIComponent(handle)}/public_profile`);
657
+ return decodeOrThrow(SubstackProfileC, raw, 'SubstackProfile');
658
+ }
659
+ }
660
+
661
+ class CommentService {
662
+ constructor(client) {
663
+ this.client = client;
664
+ }
665
+ async getCommentsForPost(postId, subdomain) {
666
+ const scope = subdomain ? { subdomain } : 'publication';
667
+ const raw = await this.client.get(`/api/v1/post/${postId}/comments`, { all_comments: true, sort: 'best_first' }, scope);
668
+ const response = decodeOrThrow(SubstackCommentsResponseC, raw, 'SubstackCommentsResponse');
669
+ return response.comments;
670
+ }
671
+ }
672
+
673
+ class FollowingService {
674
+ constructor(client) {
675
+ this.client = client;
676
+ }
677
+ async getFollowing() {
678
+ var _a;
679
+ const raw = await this.client.get('/api/v1/subscriptions/page');
680
+ const response = decodeOrThrow(SubstackSubscriptionsResponseC, raw, 'SubstackSubscriptionsResponse');
681
+ const users = [];
682
+ for (const sub of response.subscriptions) {
683
+ const author = (_a = sub.publication) === null || _a === void 0 ? void 0 : _a.author;
684
+ if (author) {
685
+ users.push({ id: author.id, handle: author.handle });
686
+ }
687
+ }
688
+ return users;
689
+ }
690
+ }
691
+
692
+ class ConnectivityService {
693
+ constructor(client) {
694
+ this.client = client;
695
+ }
696
+ async isConnected() {
697
+ try {
698
+ // Use a lightweight profile self-check as a health probe
699
+ await this.client.get('/api/v1/subscriptions/page');
700
+ return true;
701
+ }
702
+ catch (_a) {
703
+ return false;
704
+ }
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Converts a subset of markdown to Substack's Prosemirror JSON format.
710
+ *
711
+ * Supported:
712
+ * - Paragraphs (double newline separated)
713
+ * - Inline marks: **bold**, *italic*, ~~strikethrough~~, `code`
714
+ * - Bullet lists (lines starting with - or *)
715
+ * - Ordered lists (lines starting with 1. 2. etc.)
716
+ * - Blockquotes (lines starting with >)
717
+ *
718
+ * Unsupported markdown passes through as plain text.
719
+ */
720
+ // ─── Inline mark parsing ────────────────────────────────────────────────────
721
+ /**
722
+ * Parses inline markdown marks within a single line of text.
723
+ * Handles: **bold**, *italic*, ~~strikethrough~~, `code`
724
+ * Returns an array of text nodes with appropriate marks.
725
+ */
726
+ function parseInlineMarks(text) {
727
+ if (!text)
728
+ return [];
729
+ const nodes = [];
730
+ // Regex matches inline patterns in priority order:
731
+ // 1. **bold** 2. *italic* 3. ~~strikethrough~~ 4. `code`
732
+ const inlinePattern = /(\*\*(.+?)\*\*|\*(.+?)\*|~~(.+?)~~|`(.+?)`)/g;
733
+ let lastIndex = 0;
734
+ let match;
735
+ while ((match = inlinePattern.exec(text)) !== null) {
736
+ // Add any plain text before this match
737
+ if (match.index > lastIndex) {
738
+ nodes.push({ type: 'text', text: text.slice(lastIndex, match.index) });
739
+ }
740
+ if (match[2] != null) {
741
+ // **bold**
742
+ nodes.push({ type: 'text', text: match[2], marks: [{ type: 'bold' }] });
743
+ }
744
+ else if (match[3] != null) {
745
+ // *italic*
746
+ nodes.push({ type: 'text', text: match[3], marks: [{ type: 'italic' }] });
747
+ }
748
+ else if (match[4] != null) {
749
+ // ~~strikethrough~~
750
+ nodes.push({ type: 'text', text: match[4], marks: [{ type: 'strike' }] });
751
+ }
752
+ else if (match[5] != null) {
753
+ // `code`
754
+ nodes.push({ type: 'text', text: match[5], marks: [{ type: 'code' }] });
755
+ }
756
+ lastIndex = match.index + match[0].length;
757
+ }
758
+ // Add any remaining plain text
759
+ if (lastIndex < text.length) {
760
+ nodes.push({ type: 'text', text: text.slice(lastIndex) });
761
+ }
762
+ return nodes.length > 0 ? nodes : [{ type: 'text', text }];
763
+ }
764
+ // ─── Block parsing ──────────────────────────────────────────────────────────
765
+ function makeParagraph(text) {
766
+ const content = parseInlineMarks(text);
767
+ return content.length > 0 ? { type: 'paragraph', content } : { type: 'paragraph' };
768
+ }
769
+ /**
770
+ * Converts a markdown string to a Prosemirror document.
771
+ */
772
+ function markdownToProsemirror(markdown) {
773
+ const content = [];
774
+ const lines = markdown.split('\n');
775
+ let i = 0;
776
+ while (i < lines.length) {
777
+ const line = lines[i];
778
+ // Skip empty lines
779
+ if (!line.trim()) {
780
+ i++;
781
+ continue;
782
+ }
783
+ // Bullet list: collect consecutive lines starting with - or *
784
+ if (/^\s*[-*]\s+/.test(line)) {
785
+ const items = [];
786
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
787
+ const itemText = lines[i].replace(/^\s*[-*]\s+/, '');
788
+ items.push({ type: 'listItem', content: [makeParagraph(itemText)] });
789
+ i++;
790
+ }
791
+ content.push({ type: 'bulletList', content: items });
792
+ continue;
793
+ }
794
+ // Ordered list: collect consecutive lines starting with digits.
795
+ if (/^\s*\d+[.)]\s+/.test(line)) {
796
+ const items = [];
797
+ while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) {
798
+ const itemText = lines[i].replace(/^\s*\d+[.)]\s+/, '');
799
+ items.push({ type: 'listItem', content: [makeParagraph(itemText)] });
800
+ i++;
801
+ }
802
+ content.push({ type: 'orderedList', attrs: { start: 1, type: null }, content: items });
803
+ continue;
804
+ }
805
+ // Blockquote: collect consecutive lines starting with >
806
+ if (/^\s*>\s*/.test(line)) {
807
+ const quoteParagraphs = [];
808
+ while (i < lines.length && /^\s*>\s*/.test(lines[i])) {
809
+ const quoteText = lines[i].replace(/^\s*>\s*/, '');
810
+ if (quoteText.trim()) {
811
+ quoteParagraphs.push(makeParagraph(quoteText));
812
+ }
813
+ i++;
814
+ }
815
+ if (quoteParagraphs.length > 0) {
816
+ content.push({ type: 'blockquote', content: quoteParagraphs });
817
+ }
818
+ continue;
819
+ }
820
+ // Regular paragraph: collect consecutive non-empty, non-special lines
821
+ const paraLines = [];
822
+ while (i < lines.length &&
823
+ lines[i].trim() &&
824
+ !/^\s*[-*]\s+/.test(lines[i]) &&
825
+ !/^\s*\d+[.)]\s+/.test(lines[i]) &&
826
+ !/^\s*>\s*/.test(lines[i])) {
827
+ paraLines.push(lines[i]);
828
+ i++;
829
+ }
830
+ if (paraLines.length > 0) {
831
+ content.push(makeParagraph(paraLines.join(' ')));
832
+ }
833
+ }
834
+ // Ensure we always have at least one node
835
+ if (content.length === 0) {
836
+ content.push({ type: 'paragraph' });
837
+ }
838
+ return {
839
+ type: 'doc',
840
+ attrs: { schemaVersion: 'v1' },
841
+ content
842
+ };
843
+ }
844
+
845
+ class NewNoteService {
846
+ constructor(client) {
847
+ this.client = client;
848
+ }
849
+ async publishNote(content, attachmentIds) {
850
+ const bodyJson = markdownToProsemirror(content);
851
+ const body = {
852
+ bodyJson,
853
+ replyMinimumRole: 'everyone'
854
+ };
855
+ if (attachmentIds && attachmentIds.length > 0) {
856
+ body.attachmentIds = attachmentIds;
857
+ }
858
+ const raw = await this.client.post('/api/v1/comment/feed', body);
859
+ return decodeOrThrow(SubstackCreateNoteResponseC, raw, 'SubstackCreateNoteResponse');
860
+ }
861
+ }
862
+
863
+ class SubstackClient {
864
+ constructor(config) {
865
+ this.perPage = config.perPage || 25;
866
+ this.handle = config.handle;
867
+ this.client = new HttpClient({
868
+ substackSid: config.substackSid,
869
+ substackLli: config.substackLli,
870
+ publicationUrl: config.publicationUrl,
871
+ maxRequestsPerSecond: config.maxRequestsPerSecond
872
+ });
873
+ this.postService = new PostService(this.client);
874
+ this.noteService = new NoteService(this.client);
875
+ this.profileService = new ProfileService(this.client);
876
+ this.commentService = new CommentService(this.client);
877
+ this.followingService = new FollowingService(this.client);
878
+ this.connectivityService = new ConnectivityService(this.client);
879
+ this.newNoteService = new NewNoteService(this.client);
880
+ }
881
+ async testConnectivity() {
882
+ return this.connectivityService.isConnected();
883
+ }
884
+ async ownProfile() {
885
+ if (!this.handle) {
886
+ throw new Error('Cannot get own profile: "handle" must be set in SubstackConfig. ' +
887
+ 'Provide your Substack handle (e.g. "myname") in the config.');
888
+ }
889
+ try {
890
+ const profile = await this.profileService.getOwnProfile(this.handle);
891
+ return new OwnProfile(profile, this.postService, this.noteService, this.commentService, this.profileService, this.followingService, this.newNoteService, this.perPage);
892
+ }
893
+ catch (error) {
894
+ throw new Error(`Failed to get own profile: ${error.message}`);
895
+ }
896
+ }
897
+ async profileForHandle(handle) {
898
+ if (!handle || handle.trim() === '') {
899
+ throw new Error('Profile handle cannot be empty');
900
+ }
901
+ try {
902
+ const profile = await this.profileService.getProfileBySlug(handle);
903
+ return new Profile(profile, this.postService, this.noteService, this.commentService, this.perPage);
904
+ }
905
+ catch (error) {
906
+ throw new Error(`Profile with handle '${handle}' not found: ${error.message}`);
907
+ }
908
+ }
909
+ async postForSlug(slug, publicationSubdomain) {
910
+ try {
911
+ const post = await this.postService.getPostBySlug(slug, publicationSubdomain);
912
+ return new FullPost(post, this.commentService, publicationSubdomain);
913
+ }
914
+ catch (error) {
915
+ throw new Error(`Post with slug '${slug}' not found: ${error.message}`);
916
+ }
917
+ }
918
+ async close() {
919
+ await this.client.close();
920
+ }
921
+ }
922
+
923
+ exports.Comment = Comment;
924
+ exports.FullPost = FullPost;
925
+ exports.Note = Note;
926
+ exports.OwnProfile = OwnProfile;
927
+ exports.PreviewPost = PreviewPost;
928
+ exports.Profile = Profile;
929
+ exports.SubstackClient = SubstackClient;