python-substack 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
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.
- {python_substack-0.3.0.dist-info → python_substack-0.5.0.dist-info}/METADATA +3 -2
- python_substack-0.5.0.dist-info/RECORD +13 -0
- substack/__init__.py +13 -13
- substack/api.py +835 -792
- substack/cli.py +7 -1
- substack/exceptions.py +44 -32
- substack_mcp/mcp_server.py +137 -44
- python_substack-0.3.0.dist-info/RECORD +0 -13
- {python_substack-0.3.0.dist-info → python_substack-0.5.0.dist-info}/WHEEL +0 -0
- {python_substack-0.3.0.dist-info → python_substack-0.5.0.dist-info}/entry_points.txt +0 -0
- {python_substack-0.3.0.dist-info → python_substack-0.5.0.dist-info}/licenses/LICENSE +0 -0
substack/api.py
CHANGED
|
@@ -1,792 +1,835 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
API Wrapper
|
|
4
|
-
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import base64
|
|
8
|
-
import json
|
|
9
|
-
import logging
|
|
10
|
-
import os
|
|
11
|
-
from datetime import datetime
|
|
12
|
-
from urllib.parse import unquote, urljoin
|
|
13
|
-
|
|
14
|
-
import requests
|
|
15
|
-
from requests.adapters import HTTPAdapter, Retry
|
|
16
|
-
|
|
17
|
-
from substack.exceptions import SubstackAPIException, SubstackRequestException
|
|
18
|
-
|
|
19
|
-
logger = logging.getLogger(__name__)
|
|
20
|
-
|
|
21
|
-
__all__ = ["Api"]
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class Api:
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
A python interface into the Substack API
|
|
28
|
-
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
def __init__(
|
|
32
|
-
self,
|
|
33
|
-
email=None,
|
|
34
|
-
password=None,
|
|
35
|
-
cookies_path=None,
|
|
36
|
-
base_url=None,
|
|
37
|
-
publication_url=None,
|
|
38
|
-
debug=False,
|
|
39
|
-
cookies_string=None,
|
|
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
|
-
logging.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
self._session.
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
"""
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
"""
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
profile = self.get_user_profile()
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
"""
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
"""
|
|
372
|
-
response = self._session.get(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
"""
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
)
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
"""
|
|
416
|
-
response = self._session.get(f"{self.
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
"
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
draft: draft
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
draft
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
"""
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
return
|
|
670
|
-
|
|
671
|
-
def
|
|
672
|
-
"""
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
""
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
Args:
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
Returns:
|
|
752
|
-
|
|
753
|
-
"""
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
response
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1
|
+
"""
|
|
2
|
+
|
|
3
|
+
API Wrapper
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from urllib.parse import unquote, urljoin
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
from requests.adapters import HTTPAdapter, Retry
|
|
16
|
+
|
|
17
|
+
from substack.exceptions import SubstackAPIException, SubstackRequestException
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
__all__ = ["Api"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Api:
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
A python interface into the Substack API
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
email=None,
|
|
34
|
+
password=None,
|
|
35
|
+
cookies_path=None,
|
|
36
|
+
base_url=None,
|
|
37
|
+
publication_url=None,
|
|
38
|
+
debug=False,
|
|
39
|
+
cookies_string=None,
|
|
40
|
+
timeout=None,
|
|
41
|
+
):
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
To create an instance of the substack.Api class:
|
|
45
|
+
>>> import substack
|
|
46
|
+
>>> api = substack.Api(email="substack email", password="substack password")
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
email:
|
|
50
|
+
password:
|
|
51
|
+
cookies_path
|
|
52
|
+
To re-use your session without logging in each time, you can save your cookies to a json file and
|
|
53
|
+
then load them in the next session.
|
|
54
|
+
Make sure to re-save your cookies, as they do update over time.
|
|
55
|
+
cookies_string
|
|
56
|
+
To re-use your session without logging in each time, you can provide cookies as a semicolon-separated
|
|
57
|
+
string (e.g., "cookie1=value1; cookie2=value2"). This is useful when copying cookies from browser
|
|
58
|
+
developer tools.
|
|
59
|
+
base_url:
|
|
60
|
+
The base URL to use to contact the Substack API.
|
|
61
|
+
Defaults to https://substack.com/api/v1.
|
|
62
|
+
"""
|
|
63
|
+
self.base_url = base_url or "https://substack.com/api/v1"
|
|
64
|
+
|
|
65
|
+
if debug:
|
|
66
|
+
logging.basicConfig()
|
|
67
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
68
|
+
|
|
69
|
+
self.timeout = timeout
|
|
70
|
+
|
|
71
|
+
self._session = requests.Session()
|
|
72
|
+
original_request = self._session.request
|
|
73
|
+
|
|
74
|
+
def _request_with_timeout(*args, **kwargs):
|
|
75
|
+
if kwargs.get("timeout") is None and self.timeout is not None:
|
|
76
|
+
kwargs["timeout"] = self.timeout
|
|
77
|
+
return original_request(*args, **kwargs)
|
|
78
|
+
|
|
79
|
+
self._session.request = _request_with_timeout
|
|
80
|
+
|
|
81
|
+
retry = Retry(
|
|
82
|
+
total=4,
|
|
83
|
+
status=4,
|
|
84
|
+
backoff_factor=1,
|
|
85
|
+
status_forcelist=(429,),
|
|
86
|
+
allowed_methods=frozenset({"GET", "DELETE"}),
|
|
87
|
+
respect_retry_after_header=True,
|
|
88
|
+
raise_on_status=False,
|
|
89
|
+
)
|
|
90
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
91
|
+
self._session.mount("http://", adapter)
|
|
92
|
+
self._session.mount("https://", adapter)
|
|
93
|
+
|
|
94
|
+
# Load cookies from file if provided
|
|
95
|
+
# Helps with Captcha errors by reusing cookies from "local" auth, then switching to running code in the cloud
|
|
96
|
+
if cookies_path is not None:
|
|
97
|
+
with open(cookies_path) as f:
|
|
98
|
+
cookies = json.load(f)
|
|
99
|
+
self._session.cookies.update(cookies)
|
|
100
|
+
|
|
101
|
+
elif cookies_string is not None:
|
|
102
|
+
cookies = self._parse_cookies_string(cookies_string)
|
|
103
|
+
self._session.cookies.update(cookies)
|
|
104
|
+
|
|
105
|
+
elif email is not None and password is not None:
|
|
106
|
+
self.login(email, password)
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"Must provide email and password, cookies_path, or cookies_string to authenticate."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
user_publication = None
|
|
113
|
+
# if the user provided a publication url, then use that
|
|
114
|
+
if publication_url:
|
|
115
|
+
from urllib.parse import urlparse
|
|
116
|
+
|
|
117
|
+
# Normalize requested URL
|
|
118
|
+
parsed_req = urlparse(publication_url.lower())
|
|
119
|
+
req_hostname = parsed_req.hostname or parsed_req.path
|
|
120
|
+
# Strip trailing slashes and www.
|
|
121
|
+
req_hostname = req_hostname.strip("/").replace("www.", "", 1)
|
|
122
|
+
|
|
123
|
+
user_publications = self.get_user_publications()
|
|
124
|
+
for publication in user_publications:
|
|
125
|
+
pub_url = Api.get_publication_url(publication).lower()
|
|
126
|
+
parsed_pub = urlparse(pub_url)
|
|
127
|
+
pub_hostname = parsed_pub.hostname or parsed_pub.path
|
|
128
|
+
pub_hostname = pub_hostname.strip("/").replace("www.", "", 1)
|
|
129
|
+
|
|
130
|
+
if req_hostname == pub_hostname:
|
|
131
|
+
user_publication = publication
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
if user_publication is None:
|
|
135
|
+
raise SubstackRequestException(
|
|
136
|
+
f"Requested publication is unavailable: {publication_url}"
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
# get the users primary publication
|
|
140
|
+
user_publication = self.get_user_primary_publication()
|
|
141
|
+
if user_publication is None:
|
|
142
|
+
raise SubstackRequestException(
|
|
143
|
+
"Could not find primary publication in profile"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# set the current publication to the users primary publication
|
|
147
|
+
self.change_publication(user_publication)
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _parse_cookies_string(cookies_string: str) -> dict:
|
|
151
|
+
"""
|
|
152
|
+
Parse a semicolon-separated cookie string into a dictionary.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
cookies_string: A semicolon-separated string of cookies (e.g., "cookie1=value1; cookie2=value2")
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
A dictionary of cookie name-value pairs
|
|
159
|
+
"""
|
|
160
|
+
cookies = {}
|
|
161
|
+
for cookie_pair in cookies_string.split(";"):
|
|
162
|
+
cookie_pair = cookie_pair.strip()
|
|
163
|
+
if not cookie_pair:
|
|
164
|
+
continue
|
|
165
|
+
if "=" in cookie_pair:
|
|
166
|
+
key, value = cookie_pair.split("=", 1)
|
|
167
|
+
key = key.strip()
|
|
168
|
+
value = value.strip()
|
|
169
|
+
# URL decode the value (e.g., s%3A becomes s:)
|
|
170
|
+
value = unquote(value)
|
|
171
|
+
cookies[key] = value
|
|
172
|
+
return cookies
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _normalize_tags(tags):
|
|
176
|
+
if tags is None:
|
|
177
|
+
return []
|
|
178
|
+
if isinstance(tags, str):
|
|
179
|
+
return [tags]
|
|
180
|
+
return [str(tag) for tag in tags]
|
|
181
|
+
|
|
182
|
+
def login(self, email, password) -> dict:
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
Login to the substack account.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
email: substack account email
|
|
189
|
+
password: substack account password
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
response = self._session.post(
|
|
193
|
+
f"{self.base_url}/login",
|
|
194
|
+
json={
|
|
195
|
+
"captcha_response": None,
|
|
196
|
+
"email": email,
|
|
197
|
+
"for_pub": "",
|
|
198
|
+
"password": password,
|
|
199
|
+
"redirect": "/",
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return Api._handle_response(response=response)
|
|
204
|
+
|
|
205
|
+
def signin_for_pub(self, publication):
|
|
206
|
+
"""
|
|
207
|
+
Complete the signin process
|
|
208
|
+
"""
|
|
209
|
+
response = self._session.get(
|
|
210
|
+
f"https://substack.com/sign-in?redirect=%2F&for_pub={publication['subdomain']}",
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
output = Api._handle_response(response=response)
|
|
214
|
+
except SubstackRequestException as ex:
|
|
215
|
+
output = {}
|
|
216
|
+
return output
|
|
217
|
+
|
|
218
|
+
def change_publication(self, publication):
|
|
219
|
+
"""
|
|
220
|
+
Change the publication URL
|
|
221
|
+
"""
|
|
222
|
+
self.publication_url = urljoin(publication["publication_url"], "api/v1")
|
|
223
|
+
|
|
224
|
+
# sign-in to the publication
|
|
225
|
+
self.signin_for_pub(publication)
|
|
226
|
+
|
|
227
|
+
def export_cookies(self, path: str = "cookies.json"):
|
|
228
|
+
"""
|
|
229
|
+
Export cookies to a json file.
|
|
230
|
+
Args:
|
|
231
|
+
path: path to the json file
|
|
232
|
+
"""
|
|
233
|
+
cookies = self._session.cookies.get_dict()
|
|
234
|
+
with open(path, "w") as f:
|
|
235
|
+
json.dump(cookies, f)
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _handle_response(response: requests.Response):
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
Internal helper for handling API responses from the Substack server.
|
|
242
|
+
Raises the appropriate exceptions when necessary; otherwise, returns the
|
|
243
|
+
response.
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
if not (200 <= response.status_code < 300):
|
|
248
|
+
raise SubstackAPIException(response.status_code, response.text)
|
|
249
|
+
try:
|
|
250
|
+
return response.json()
|
|
251
|
+
except ValueError:
|
|
252
|
+
raise SubstackRequestException("Invalid Response: %s" % response.text)
|
|
253
|
+
|
|
254
|
+
def get_user_id(self):
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
|
|
259
|
+
"""
|
|
260
|
+
profile = self.get_user_profile()
|
|
261
|
+
user_id = profile["id"]
|
|
262
|
+
|
|
263
|
+
return user_id
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def get_publication_url(publication: dict) -> str:
|
|
267
|
+
"""
|
|
268
|
+
Gets the publication url
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
publication:
|
|
272
|
+
"""
|
|
273
|
+
custom_domain = publication.get("custom_domain", None)
|
|
274
|
+
if not custom_domain and not publication.get("custom_domain_optional", None):
|
|
275
|
+
publication_url = f"https://{publication['subdomain']}.substack.com"
|
|
276
|
+
else:
|
|
277
|
+
publication_url = f"https://{custom_domain}"
|
|
278
|
+
|
|
279
|
+
return publication_url
|
|
280
|
+
|
|
281
|
+
def get_user_primary_publication(self):
|
|
282
|
+
"""
|
|
283
|
+
Gets the users primary publication
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
profile = self.get_user_profile()
|
|
287
|
+
primary_publication = None
|
|
288
|
+
|
|
289
|
+
# Try old API format first (backward compatibility)
|
|
290
|
+
if (
|
|
291
|
+
"primaryPublication" in profile
|
|
292
|
+
and profile["primaryPublication"] is not None
|
|
293
|
+
):
|
|
294
|
+
primary_publication = profile["primaryPublication"]
|
|
295
|
+
else:
|
|
296
|
+
# New API format: look for primary publication in publicationUsers
|
|
297
|
+
publication_users = profile.get("publicationUsers")
|
|
298
|
+
if publication_users is not None and len(publication_users) > 0:
|
|
299
|
+
# Find the publication where is_primary is True
|
|
300
|
+
for pub_user in publication_users:
|
|
301
|
+
if pub_user.get("is_primary", False):
|
|
302
|
+
primary_publication = pub_user.get("publication")
|
|
303
|
+
if primary_publication:
|
|
304
|
+
break
|
|
305
|
+
|
|
306
|
+
# If no primary found, use the first publication
|
|
307
|
+
if primary_publication is None:
|
|
308
|
+
primary_publication = publication_users[0].get("publication")
|
|
309
|
+
|
|
310
|
+
if primary_publication is None:
|
|
311
|
+
raise SubstackRequestException(
|
|
312
|
+
"Could not find primary publication in profile"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
primary_publication["publication_url"] = self.get_publication_url(
|
|
316
|
+
primary_publication
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return primary_publication
|
|
320
|
+
|
|
321
|
+
def get_user_publications(self):
|
|
322
|
+
"""
|
|
323
|
+
Gets the users publications
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
profile = self.get_user_profile()
|
|
327
|
+
|
|
328
|
+
# Loop through users "publicationUsers" list, and return a list
|
|
329
|
+
# of dictionaries of "name", and "subdomain", and "id"
|
|
330
|
+
user_publications = []
|
|
331
|
+
publication_users = profile.get("publicationUsers")
|
|
332
|
+
|
|
333
|
+
if publication_users is None:
|
|
334
|
+
# If publicationUsers is None, return empty list or try to construct from other fields
|
|
335
|
+
# This maintains backward compatibility while handling new API format
|
|
336
|
+
return user_publications
|
|
337
|
+
|
|
338
|
+
for publication in publication_users:
|
|
339
|
+
pub = publication.get("publication")
|
|
340
|
+
if pub is not None:
|
|
341
|
+
pub["publication_url"] = self.get_publication_url(pub)
|
|
342
|
+
user_publications.append(pub)
|
|
343
|
+
|
|
344
|
+
return user_publications
|
|
345
|
+
|
|
346
|
+
def get_user_profile(self):
|
|
347
|
+
"""
|
|
348
|
+
Gets the users profile
|
|
349
|
+
"""
|
|
350
|
+
response = self._session.get(f"{self.base_url}/user/profile/self")
|
|
351
|
+
|
|
352
|
+
return Api._handle_response(response=response)
|
|
353
|
+
|
|
354
|
+
def get_user_settings(self):
|
|
355
|
+
"""
|
|
356
|
+
Get list of users.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
|
|
360
|
+
"""
|
|
361
|
+
response = self._session.get(f"{self.base_url}/settings")
|
|
362
|
+
|
|
363
|
+
return Api._handle_response(response=response)
|
|
364
|
+
|
|
365
|
+
def get_publication_users(self):
|
|
366
|
+
"""
|
|
367
|
+
Get list of users.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
|
|
371
|
+
"""
|
|
372
|
+
response = self._session.get(f"{self.publication_url}/publication/users")
|
|
373
|
+
|
|
374
|
+
return Api._handle_response(response=response)
|
|
375
|
+
|
|
376
|
+
def get_publication_subscriber_count(self):
|
|
377
|
+
"""
|
|
378
|
+
Get subscriber count.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
|
|
382
|
+
"""
|
|
383
|
+
response = self._session.get(
|
|
384
|
+
f"{self.publication_url}/publication_launch_checklist"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
data = Api._handle_response(response=response)
|
|
388
|
+
if "subscriberCount" in data:
|
|
389
|
+
return data["subscriberCount"]
|
|
390
|
+
return len(data["subscribers"])
|
|
391
|
+
|
|
392
|
+
def get_published_posts(
|
|
393
|
+
self, offset=0, limit=25, order_by="post_date", order_direction="desc"
|
|
394
|
+
):
|
|
395
|
+
"""
|
|
396
|
+
Get list of published posts for the publication.
|
|
397
|
+
"""
|
|
398
|
+
response = self._session.get(
|
|
399
|
+
f"{self.publication_url}/post_management/published",
|
|
400
|
+
params={
|
|
401
|
+
"offset": offset,
|
|
402
|
+
"limit": limit,
|
|
403
|
+
"order_by": order_by,
|
|
404
|
+
"order_direction": order_direction,
|
|
405
|
+
},
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
return Api._handle_response(response=response)
|
|
409
|
+
|
|
410
|
+
def get_posts(self) -> dict:
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
|
|
415
|
+
"""
|
|
416
|
+
response = self._session.get(f"{self.base_url}/reader/posts")
|
|
417
|
+
|
|
418
|
+
return Api._handle_response(response=response)
|
|
419
|
+
|
|
420
|
+
def get_drafts(self, filter=None, offset=None, limit=None):
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
filter:
|
|
425
|
+
offset:
|
|
426
|
+
limit:
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
|
|
430
|
+
"""
|
|
431
|
+
response = self._session.get(
|
|
432
|
+
f"{self.publication_url}/drafts",
|
|
433
|
+
params={"filter": filter, "offset": offset, "limit": limit},
|
|
434
|
+
)
|
|
435
|
+
return Api._handle_response(response=response)
|
|
436
|
+
|
|
437
|
+
def get_draft(self, draft_id):
|
|
438
|
+
"""
|
|
439
|
+
Gets a draft given it's id.
|
|
440
|
+
|
|
441
|
+
"""
|
|
442
|
+
response = self._session.get(f"{self.publication_url}/drafts/{draft_id}")
|
|
443
|
+
return Api._handle_response(response=response)
|
|
444
|
+
|
|
445
|
+
def delete_draft(self, draft_id):
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
draft_id:
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
|
|
453
|
+
"""
|
|
454
|
+
response = self._session.delete(f"{self.publication_url}/drafts/{draft_id}")
|
|
455
|
+
return Api._handle_response(response=response)
|
|
456
|
+
|
|
457
|
+
def post_draft(self, body) -> dict:
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
body:
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
|
|
465
|
+
"""
|
|
466
|
+
response = self._session.post(f"{self.publication_url}/drafts", json=body)
|
|
467
|
+
return Api._handle_response(response=response)
|
|
468
|
+
|
|
469
|
+
def create_draft_from_markdown(
|
|
470
|
+
self,
|
|
471
|
+
title: str,
|
|
472
|
+
markdown: str,
|
|
473
|
+
subtitle: str = "",
|
|
474
|
+
audience: str = "everyone",
|
|
475
|
+
write_comment_permissions: str = "everyone",
|
|
476
|
+
search_engine_title: str = None,
|
|
477
|
+
search_engine_description: str = None,
|
|
478
|
+
slug: str = None,
|
|
479
|
+
draft_section_id: int = None,
|
|
480
|
+
tags=None,
|
|
481
|
+
prepublish: bool = False,
|
|
482
|
+
publish: bool = False,
|
|
483
|
+
send: bool = True,
|
|
484
|
+
share_automatically: bool = False,
|
|
485
|
+
) -> dict:
|
|
486
|
+
from substack.post import Post
|
|
487
|
+
|
|
488
|
+
post = Post(
|
|
489
|
+
title=title,
|
|
490
|
+
subtitle=subtitle or "",
|
|
491
|
+
user_id=self.get_user_id(),
|
|
492
|
+
audience=audience,
|
|
493
|
+
write_comment_permissions=write_comment_permissions,
|
|
494
|
+
)
|
|
495
|
+
post.from_markdown(markdown, api=self)
|
|
496
|
+
|
|
497
|
+
draft = self.post_draft(post.get_draft())
|
|
498
|
+
draft_id = draft.get("id")
|
|
499
|
+
|
|
500
|
+
update_payload = {
|
|
501
|
+
"search_engine_title": search_engine_title,
|
|
502
|
+
"search_engine_description": search_engine_description,
|
|
503
|
+
"slug": slug,
|
|
504
|
+
"draft_section_id": draft_section_id,
|
|
505
|
+
}
|
|
506
|
+
update_payload = {
|
|
507
|
+
key: value for key, value in update_payload.items() if value is not None
|
|
508
|
+
}
|
|
509
|
+
if update_payload:
|
|
510
|
+
draft = self.put_draft(draft_id, **update_payload)
|
|
511
|
+
|
|
512
|
+
tags_result = None
|
|
513
|
+
tags_list = self._normalize_tags(tags)
|
|
514
|
+
if tags_list:
|
|
515
|
+
tags_result = self.add_tags_to_post(draft_id, tags_list)
|
|
516
|
+
|
|
517
|
+
prepublish_result = None
|
|
518
|
+
if prepublish:
|
|
519
|
+
prepublish_result = self.prepublish_draft(draft_id)
|
|
520
|
+
|
|
521
|
+
publish_result = None
|
|
522
|
+
if publish:
|
|
523
|
+
publish_result = self.publish_draft(
|
|
524
|
+
draft_id,
|
|
525
|
+
send=send,
|
|
526
|
+
share_automatically=share_automatically,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
"draft": draft,
|
|
531
|
+
"tags": tags_result,
|
|
532
|
+
"prepublish": prepublish_result,
|
|
533
|
+
"publish": publish_result,
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
def put_draft(self, draft, **kwargs) -> dict:
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
draft:
|
|
541
|
+
**kwargs:
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
|
|
545
|
+
"""
|
|
546
|
+
response = self._session.put(
|
|
547
|
+
f"{self.publication_url}/drafts/{draft}",
|
|
548
|
+
json=kwargs,
|
|
549
|
+
)
|
|
550
|
+
return Api._handle_response(response=response)
|
|
551
|
+
|
|
552
|
+
def prepublish_draft(self, draft) -> dict:
|
|
553
|
+
"""
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
draft: draft id
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
|
|
560
|
+
"""
|
|
561
|
+
|
|
562
|
+
response = self._session.get(
|
|
563
|
+
f"{self.publication_url}/drafts/{draft}/prepublish"
|
|
564
|
+
)
|
|
565
|
+
return Api._handle_response(response=response)
|
|
566
|
+
|
|
567
|
+
def publish_draft(
|
|
568
|
+
self, draft, send: bool = True, share_automatically: bool = False
|
|
569
|
+
) -> dict:
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
draft: draft id
|
|
574
|
+
send:
|
|
575
|
+
share_automatically:
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
|
|
579
|
+
"""
|
|
580
|
+
response = self._session.post(
|
|
581
|
+
f"{self.publication_url}/drafts/{draft}/publish",
|
|
582
|
+
json={"send": send, "share_automatically": share_automatically},
|
|
583
|
+
)
|
|
584
|
+
return Api._handle_response(response=response)
|
|
585
|
+
|
|
586
|
+
def schedule_draft(self, draft, draft_datetime: datetime) -> dict:
|
|
587
|
+
"""
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
draft: draft id
|
|
591
|
+
draft_datetime: datetime to schedule the draft
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
|
|
595
|
+
"""
|
|
596
|
+
response = self._session.post(
|
|
597
|
+
f"{self.publication_url}/drafts/{draft}/scheduled_release",
|
|
598
|
+
json={"trigger_at": draft_datetime.isoformat()},
|
|
599
|
+
)
|
|
600
|
+
return Api._handle_response(response=response)
|
|
601
|
+
|
|
602
|
+
def unschedule_draft(self, draft) -> dict:
|
|
603
|
+
"""
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
draft: draft id
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
|
|
610
|
+
"""
|
|
611
|
+
response = self._session.delete(
|
|
612
|
+
f"{self.publication_url}/drafts/{draft}/scheduled_release"
|
|
613
|
+
)
|
|
614
|
+
return Api._handle_response(response=response)
|
|
615
|
+
|
|
616
|
+
def get_image(self, image: str):
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
This method generates a new substack link that contains the image.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
image: filepath or original url of image.
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
|
|
626
|
+
"""
|
|
627
|
+
if os.path.exists(image):
|
|
628
|
+
import mimetypes
|
|
629
|
+
|
|
630
|
+
mime_type, _ = mimetypes.guess_type(image)
|
|
631
|
+
if mime_type not in ["image/jpeg", "image/png", "image/gif", "image/webp"]:
|
|
632
|
+
ext = os.path.splitext(image)[1].lower()
|
|
633
|
+
if ext in [".jpg", ".jpeg"]:
|
|
634
|
+
mime_type = "image/jpeg"
|
|
635
|
+
elif ext == ".png":
|
|
636
|
+
mime_type = "image/png"
|
|
637
|
+
elif ext == ".gif":
|
|
638
|
+
mime_type = "image/gif"
|
|
639
|
+
elif ext == ".webp":
|
|
640
|
+
mime_type = "image/webp"
|
|
641
|
+
else:
|
|
642
|
+
mime_type = "application/octet-stream"
|
|
643
|
+
|
|
644
|
+
with open(image, "rb") as file:
|
|
645
|
+
image_bytes = base64.b64encode(file.read())
|
|
646
|
+
image = f"data:{mime_type};base64,".encode("ascii") + image_bytes
|
|
647
|
+
|
|
648
|
+
response = self._session.post(
|
|
649
|
+
f"{self.publication_url}/image",
|
|
650
|
+
data={"image": image},
|
|
651
|
+
)
|
|
652
|
+
return Api._handle_response(response=response)
|
|
653
|
+
|
|
654
|
+
def add_tags_to_post(self, post_id: int, tag_names: list) -> dict:
|
|
655
|
+
"""
|
|
656
|
+
Add multiple tags to a post.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
post_id: The ID of the post to tag.
|
|
660
|
+
tag_names: A list of tag names to add.
|
|
661
|
+
|
|
662
|
+
Returns:
|
|
663
|
+
A dictionary with the results of applying all tags.
|
|
664
|
+
"""
|
|
665
|
+
results = []
|
|
666
|
+
for tag_name in tag_names:
|
|
667
|
+
result = self.add_tag_to_post(post_id, tag_name)
|
|
668
|
+
results.append(result)
|
|
669
|
+
return {"tags_added": results}
|
|
670
|
+
|
|
671
|
+
def get_publication_post_tags(self) -> list:
|
|
672
|
+
"""
|
|
673
|
+
Retrieve all post tags for the current publication.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
List of tag dicts as returned by Substack API.
|
|
677
|
+
"""
|
|
678
|
+
response = self._session.get(f"{self.publication_url}/publication/post-tag")
|
|
679
|
+
return Api._handle_response(response=response)
|
|
680
|
+
|
|
681
|
+
def add_tag_to_post(self, post_id: int, tag_name: str) -> dict:
|
|
682
|
+
"""
|
|
683
|
+
Add a tag to a post by first checking published tags and creating only if needed.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
post_id: The ID of the post to tag.
|
|
687
|
+
tag_name: The name of the tag to add.
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
The response from applying the tag to the post.
|
|
691
|
+
"""
|
|
692
|
+
# Fetch existing publication tags first (avoid re-creating an already existing tag)
|
|
693
|
+
existing_tags = self.get_publication_post_tags() or []
|
|
694
|
+
existing_tag = next(
|
|
695
|
+
(tag for tag in existing_tags if tag.get("name") == tag_name),
|
|
696
|
+
None,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
if existing_tag is not None:
|
|
700
|
+
tag_id = existing_tag["id"]
|
|
701
|
+
else:
|
|
702
|
+
create_tag_response = self._session.post(
|
|
703
|
+
f"{self.publication_url}/publication/post-tag",
|
|
704
|
+
json={"name": tag_name},
|
|
705
|
+
)
|
|
706
|
+
tag_data = Api._handle_response(create_tag_response)
|
|
707
|
+
tag_id = tag_data["id"]
|
|
708
|
+
|
|
709
|
+
apply_tag_response = self._session.post(
|
|
710
|
+
f"{self.publication_url}/post/{post_id}/tag/{tag_id}",
|
|
711
|
+
)
|
|
712
|
+
return Api._handle_response(apply_tag_response)
|
|
713
|
+
|
|
714
|
+
def get_categories(self):
|
|
715
|
+
"""
|
|
716
|
+
|
|
717
|
+
Retrieve list of all available categories.
|
|
718
|
+
|
|
719
|
+
Returns:
|
|
720
|
+
|
|
721
|
+
"""
|
|
722
|
+
response = self._session.get(f"{self.base_url}/categories")
|
|
723
|
+
return Api._handle_response(response=response)
|
|
724
|
+
|
|
725
|
+
def get_category(self, category_id, category_type, page):
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
category_id:
|
|
730
|
+
category_type:
|
|
731
|
+
page:
|
|
732
|
+
|
|
733
|
+
Returns:
|
|
734
|
+
|
|
735
|
+
"""
|
|
736
|
+
response = self._session.get(
|
|
737
|
+
f"{self.base_url}/category/public/{category_id}/{category_type}",
|
|
738
|
+
params={"page": page},
|
|
739
|
+
)
|
|
740
|
+
return Api._handle_response(response=response)
|
|
741
|
+
|
|
742
|
+
def get_single_category(self, category_id, category_type, page=None, limit=None):
|
|
743
|
+
"""
|
|
744
|
+
|
|
745
|
+
Args:
|
|
746
|
+
category_id:
|
|
747
|
+
category_type: paid or all
|
|
748
|
+
page: by default substack retrieves only the first 25 publications in the category. If this is left None,
|
|
749
|
+
then all pages will be retrieved. The page size is 25 publications.
|
|
750
|
+
limit:
|
|
751
|
+
Returns:
|
|
752
|
+
|
|
753
|
+
"""
|
|
754
|
+
if page is not None:
|
|
755
|
+
output = self.get_category(category_id, category_type, page)
|
|
756
|
+
else:
|
|
757
|
+
publications = []
|
|
758
|
+
page = 0
|
|
759
|
+
while True:
|
|
760
|
+
page_output = self.get_category(category_id, category_type, page)
|
|
761
|
+
publications.extend(page_output.get("publications", []))
|
|
762
|
+
if (
|
|
763
|
+
limit is not None and limit <= len(publications)
|
|
764
|
+
) or not page_output.get("more", False):
|
|
765
|
+
publications = publications[:limit]
|
|
766
|
+
break
|
|
767
|
+
page += 1
|
|
768
|
+
output = {
|
|
769
|
+
"publications": publications,
|
|
770
|
+
"more": page_output.get("more", False),
|
|
771
|
+
}
|
|
772
|
+
return output
|
|
773
|
+
|
|
774
|
+
def delete_all_drafts(self):
|
|
775
|
+
"""
|
|
776
|
+
|
|
777
|
+
Returns:
|
|
778
|
+
|
|
779
|
+
"""
|
|
780
|
+
response = None
|
|
781
|
+
while True:
|
|
782
|
+
drafts = self.get_drafts(filter="draft", limit=10, offset=0)
|
|
783
|
+
if len(drafts) == 0:
|
|
784
|
+
break
|
|
785
|
+
for draft in drafts:
|
|
786
|
+
response = self.delete_draft(draft.get("id"))
|
|
787
|
+
return response
|
|
788
|
+
|
|
789
|
+
def get_sections(self):
|
|
790
|
+
"""
|
|
791
|
+
Get a list of the sections of your publication.
|
|
792
|
+
|
|
793
|
+
TODO: this is hacky but I cannot find another place where to get the sections.
|
|
794
|
+
Returns:
|
|
795
|
+
|
|
796
|
+
"""
|
|
797
|
+
response = self._session.get(
|
|
798
|
+
f"{self.publication_url}/subscriptions",
|
|
799
|
+
)
|
|
800
|
+
content = Api._handle_response(response=response)
|
|
801
|
+
sections = [
|
|
802
|
+
p.get("sections")
|
|
803
|
+
for p in content.get("publications")
|
|
804
|
+
if p.get("hostname") in self.publication_url
|
|
805
|
+
]
|
|
806
|
+
return sections[0]
|
|
807
|
+
|
|
808
|
+
def publication_embed(self, url):
|
|
809
|
+
"""
|
|
810
|
+
|
|
811
|
+
Args:
|
|
812
|
+
url:
|
|
813
|
+
|
|
814
|
+
Returns:
|
|
815
|
+
|
|
816
|
+
"""
|
|
817
|
+
return self.call("/publication/embed", "GET", url=url)
|
|
818
|
+
|
|
819
|
+
def call(self, endpoint, method, **params):
|
|
820
|
+
"""
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
endpoint:
|
|
824
|
+
method:
|
|
825
|
+
**params:
|
|
826
|
+
|
|
827
|
+
Returns:
|
|
828
|
+
|
|
829
|
+
"""
|
|
830
|
+
response = self._session.request(
|
|
831
|
+
method=method,
|
|
832
|
+
url=f"{self.publication_url}/{endpoint}",
|
|
833
|
+
params=params,
|
|
834
|
+
)
|
|
835
|
+
return Api._handle_response(response=response)
|