├── .github └── workflows │ └── ci_publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── examples ├── draft.yaml ├── get_subscriber_count.py ├── publish_post.py └── rickroll_4k.jpg ├── poetry.lock ├── pyproject.toml ├── substack ├── __init__.py ├── api.py ├── exceptions.py └── post.py └── tests ├── __init__.py └── substack ├── __init__.py └── test_api.py /.github/workflows/ci_publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Publish python poetry package 12 | uses: JRubics/poetry-publish@v1.15 13 | with: 14 | pypi_token: ${{ secrets.PYPI_TOKEN }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .idea/ 132 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/psf/black 9 | rev: 22.10.0 10 | hooks: 11 | - id: black 12 | - repo: https://github.com/pycqa/isort 13 | rev: 5.12.0 14 | hooks: 15 | - id: isort 16 | name: isort (python) 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 ma2za 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Substack 2 | 3 | This is an unofficial library providing a Python interface for [Substack](https://substack.com/). 4 | I am in no way affiliated with Substack. 5 | 6 | [![Python](https://img.shields.io/pypi/pyversions/fastapi.svg?color=%2334D058)](https://www.python.org/downloads/) 7 | [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack) 8 | ![Release Build](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg) 9 | --- 10 | 11 | # Installation 12 | 13 | You can install python-substack using: 14 | 15 | $ pip install python-substack 16 | 17 | --- 18 | 19 | # Setup 20 | 21 | Set the following environment variables by creating a **.env** file: 22 | 23 | EMAIL= 24 | PASSWORD= 25 | 26 | ## If you don't have a password 27 | 28 | Recently Substack has been setting up new accounts without a password. If you sign-out and sign back in it just uses 29 | your email address with a "magic" link. 30 | 31 | Set a password: 32 | 33 | - Sign-out of Substack 34 | - At the sign-in page click, "Sign in with password" under the `Email` text box 35 | - Then choose, "Set a new password" 36 | 37 | The .env file will be ignored by git but always be careful. 38 | 39 | --- 40 | 41 | # Usage 42 | 43 | Check out the examples folder for some examples 😃 🚀 44 | 45 | ```python 46 | import os 47 | 48 | from substack import Api 49 | from substack.post import Post 50 | 51 | api = Api( 52 | email=os.getenv("EMAIL"), 53 | password=os.getenv("PASSWORD"), 54 | ) 55 | 56 | user_id = api.get_user_id() 57 | 58 | # Switch Publications - The library defaults to your users primary publication. You can retrieve all your publications and change which one you want to use. 59 | 60 | # primary publication 61 | user_publication = api.get_user_primary_publication() 62 | # all publications 63 | user_publications = api.get_user_publications() 64 | 65 | # This step is only necessary if you are not using your primary publication 66 | # api.change_publication(user_publication) 67 | 68 | post = Post( 69 | title="How to publish a Substack post using the Python API", 70 | subtitle="This post was published using the Python API", 71 | user_id=user_id 72 | ) 73 | 74 | post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'}) 75 | 76 | # bolden text 77 | post.add({'type': "paragraph", 78 | 'content': [{'content': "This is how you "}, {'content': "bolden ", 'marks': [{'type': "strong"}]}, 79 | {'content': "a word."}]}) 80 | 81 | # add hyperlink to text 82 | post.add({'type': 'paragraph', 'content': [ 83 | {'content': "View Link", 'marks': [{'type': "link", 'href': 'https://whoraised.substack.com/'}]}]}) 84 | 85 | # set paywall boundary 86 | post.add({'type': 'paywall'}) 87 | 88 | # add image 89 | post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif"}) 90 | 91 | # add local image 92 | image = api.get_image('image.png') 93 | post.add({"type": "captionedImage", "src": image.get("url")}) 94 | 95 | # embed publication 96 | embedded = api.publication_embed("https://jackio.substack.com/") 97 | post.add({"type": "embeddedPublication", "url": embedded}) 98 | 99 | draft = api.post_draft(post.get_draft()) 100 | 101 | # set section (THIS CAN BE DONE ONLY AFTER HAVING FIRST POSTED THE DRAFT) 102 | post.set_section("rick rolling", api.get_sections()) 103 | api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id) 104 | 105 | api.prepublish_draft(draft.get("id")) 106 | 107 | api.publish_draft(draft.get("id")) 108 | ``` 109 | 110 | # Contributing 111 | 112 | Install pre-commit: 113 | 114 | ```shell 115 | pip install pre-commit 116 | ``` 117 | 118 | Set up pre-commit 119 | 120 | ```shell 121 | pre-commit install 122 | ``` 123 | -------------------------------------------------------------------------------- /examples/draft.yaml: -------------------------------------------------------------------------------- 1 | title: 2 | "How to publish a Substack post using the Python API" 3 | subtitle: 4 | "This post was published using the Python API" 5 | audience: 6 | "everyone" # everyone, only_paid, founding, only_free 7 | write_comment_permissions: 8 | "none" # none, only_paid, everyone 9 | section: 10 | "rick" 11 | body: 12 | 0: 13 | type: "heading" 14 | level: 1 15 | content: "Steps" 16 | 1: 17 | type: "paragraph" 18 | content: "1)" 19 | marks: 20 | - type: "strong" 21 | - type: "em" 22 | 2: 23 | type: "paragraph" 24 | content: 25 | - content: "hello" 26 | marks: 27 | - type: "strong" 28 | - type: "em" 29 | - content: ", how are you?" 30 | 10: 31 | type: "paragraph" 32 | content: "my friend" 33 | 3: 34 | type: "horizontal_rule" 35 | 4: 36 | type: "paragraph" 37 | content: "2)" 38 | 5: 39 | type: "captionedImage" 40 | src: "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif" 41 | 6: 42 | type: "paragraph" 43 | content: "Set the EMAIL, PASSWORD, PUBLICATION_URL and USER_ID environment variables." 44 | 7: 45 | type: "captionedImage" 46 | src: "rickroll_4k.jpg" 47 | 8: 48 | type: "youtube2" 49 | src: "EnDg65ISswg" 50 | 9: 51 | type: "subscribeWidget" 52 | message: "Hello Everyone!!!" 53 | -------------------------------------------------------------------------------- /examples/get_subscriber_count.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | from substack import Api 6 | 7 | load_dotenv() 8 | 9 | if __name__ == "__main__": 10 | api = Api( 11 | email=os.getenv("EMAIL"), 12 | password=os.getenv("PASSWORD"), 13 | publication_url=os.getenv("PUBLICATION_URL"), 14 | ) 15 | 16 | subscriberCount: int = api.get_publication_subscriber_count() 17 | print(f"Subscriber count: {subscriberCount}") 18 | -------------------------------------------------------------------------------- /examples/publish_post.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | 4 | import yaml 5 | from dotenv import load_dotenv 6 | 7 | from substack import Api 8 | from substack.post import Post 9 | 10 | load_dotenv() 11 | 12 | if __name__ == "__main__": 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument( 15 | "-p", 16 | "--post", 17 | default="draft.yaml", 18 | required=False, 19 | help="YAML file containing the post to publish.", 20 | type=str, 21 | ) 22 | parser.add_argument( 23 | "--publish", help="Publish the draft.", action="store_true", default=True 24 | ) 25 | args = parser.parse_args() 26 | 27 | with open(args.post, "r") as fp: 28 | post_data = yaml.safe_load(fp) 29 | 30 | api = Api( 31 | email=os.getenv("EMAIL"), 32 | password=os.getenv("PASSWORD"), 33 | publication_url=os.getenv("PUBLICATION_URL"), 34 | ) 35 | 36 | post = Post( 37 | post_data.get("title"), 38 | post_data.get("subtitle", ""), 39 | os.getenv("USER_ID"), 40 | audience=post_data.get("audience", "everyone"), 41 | write_comment_permissions=post_data.get( 42 | "write_comment_permissions", "everyone" 43 | ), 44 | ) 45 | 46 | body = post_data.get("body", {}) 47 | 48 | for _, item in body.items(): 49 | if item.get("type") == "captionedImage": 50 | image = api.get_image(item.get("src")) 51 | item.update({"src": image.get("url")}) 52 | post.add(item) 53 | 54 | draft = api.post_draft(post.get_draft()) 55 | 56 | post.set_section(post_data.get("section"), api.get_sections()) 57 | api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id) 58 | 59 | if args.publish: 60 | api.prepublish_draft(draft.get("id")) 61 | 62 | api.publish_draft(draft.get("id")) 63 | -------------------------------------------------------------------------------- /examples/rickroll_4k.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma2za/python-substack/d79f1ca067bc3fea1ee70e742e034ffb8d3daf37/examples/rickroll_4k.jpg -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "certifi" 5 | version = "2023.11.17" 6 | description = "Python package for providing Mozilla's CA Bundle." 7 | optional = false 8 | python-versions = ">=3.6" 9 | files = [ 10 | {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, 11 | {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, 12 | ] 13 | 14 | [[package]] 15 | name = "charset-normalizer" 16 | version = "3.3.2" 17 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 18 | optional = false 19 | python-versions = ">=3.7.0" 20 | files = [ 21 | {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, 22 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, 23 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, 24 | {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, 25 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, 26 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, 27 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, 28 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, 29 | {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, 30 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, 31 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, 32 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, 33 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, 34 | {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, 35 | {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, 36 | {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, 37 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, 38 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, 39 | {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, 40 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, 41 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, 42 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, 43 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, 44 | {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, 45 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, 46 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, 47 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, 48 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, 49 | {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, 50 | {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, 51 | {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, 52 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, 53 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, 54 | {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, 55 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, 56 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, 57 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, 58 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, 59 | {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, 60 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, 61 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, 62 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, 63 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, 64 | {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, 65 | {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, 66 | {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, 67 | {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, 68 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, 69 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, 70 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, 71 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, 72 | {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, 73 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, 74 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, 75 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, 76 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, 77 | {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, 78 | {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, 79 | {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, 80 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, 81 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, 82 | {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, 83 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, 84 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, 85 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, 86 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, 87 | {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, 88 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, 89 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, 90 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, 91 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, 92 | {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, 93 | {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, 94 | {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, 95 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, 96 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, 97 | {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, 98 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, 99 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, 100 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, 101 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, 102 | {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, 103 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, 104 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, 105 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, 106 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, 107 | {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, 108 | {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, 109 | {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, 110 | {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, 111 | ] 112 | 113 | [[package]] 114 | name = "idna" 115 | version = "3.6" 116 | description = "Internationalized Domain Names in Applications (IDNA)" 117 | optional = false 118 | python-versions = ">=3.5" 119 | files = [ 120 | {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, 121 | {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, 122 | ] 123 | 124 | [[package]] 125 | name = "python-dotenv" 126 | version = "0.21.1" 127 | description = "Read key-value pairs from a .env file and set them as environment variables" 128 | optional = false 129 | python-versions = ">=3.7" 130 | files = [ 131 | {file = "python-dotenv-0.21.1.tar.gz", hash = "sha256:1c93de8f636cde3ce377292818d0e440b6e45a82f215c3744979151fa8151c49"}, 132 | {file = "python_dotenv-0.21.1-py3-none-any.whl", hash = "sha256:41e12e0318bebc859fcc4d97d4db8d20ad21721a6aa5047dd59f090391cb549a"}, 133 | ] 134 | 135 | [package.extras] 136 | cli = ["click (>=5.0)"] 137 | 138 | [[package]] 139 | name = "pyyaml" 140 | version = "6.0.1" 141 | description = "YAML parser and emitter for Python" 142 | optional = false 143 | python-versions = ">=3.6" 144 | files = [ 145 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, 146 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, 147 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, 148 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, 149 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, 150 | {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, 151 | {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, 152 | {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, 153 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, 154 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, 155 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, 156 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, 157 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, 158 | {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, 159 | {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, 160 | {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, 161 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, 162 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, 163 | {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, 164 | {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, 165 | {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, 166 | {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, 167 | {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, 168 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, 169 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, 170 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, 171 | {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, 172 | {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, 173 | {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, 174 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, 175 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, 176 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, 177 | {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, 178 | {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, 179 | {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, 180 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, 181 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, 182 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, 183 | {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, 184 | {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, 185 | {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, 186 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, 187 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, 188 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, 189 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, 190 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, 191 | {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, 192 | {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, 193 | {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, 194 | {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, 195 | ] 196 | 197 | [[package]] 198 | name = "requests" 199 | version = "2.31.0" 200 | description = "Python HTTP for Humans." 201 | optional = false 202 | python-versions = ">=3.7" 203 | files = [ 204 | {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, 205 | {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, 206 | ] 207 | 208 | [package.dependencies] 209 | certifi = ">=2017.4.17" 210 | charset-normalizer = ">=2,<4" 211 | idna = ">=2.5,<4" 212 | urllib3 = ">=1.21.1,<3" 213 | 214 | [package.extras] 215 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 216 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 217 | 218 | [[package]] 219 | name = "urllib3" 220 | version = "2.0.7" 221 | description = "HTTP library with thread-safe connection pooling, file post, and more." 222 | optional = false 223 | python-versions = ">=3.7" 224 | files = [ 225 | {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, 226 | {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, 227 | ] 228 | 229 | [package.extras] 230 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 231 | secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] 232 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 233 | zstd = ["zstandard (>=0.18.0)"] 234 | 235 | [metadata] 236 | lock-version = "2.0" 237 | python-versions = "^3.7" 238 | content-hash = "35de245f8f35fd39d3f31a8a83c4097762df0a30fb5cefd8d84464e5ba058680" 239 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "python-substack" 3 | version = "0.1.15" 4 | description = "A Python wrapper around the Substack API." 5 | authors = ["Paolo Mazza "] 6 | license = "MIT" 7 | packages = [ 8 | { include = "substack" } 9 | ] 10 | 11 | readme = "README.md" 12 | 13 | repository = "https://github.com/ma2za/python-substack" 14 | homepage = "https://github.com/ma2za/python-substack" 15 | 16 | keywords = ["substack"] 17 | 18 | [tool.poetry.dependencies] 19 | python = "^3.7" 20 | 21 | requests = "^2.31.0" 22 | python-dotenv = "^0.21.0" 23 | PyYAML = "^6.0" 24 | 25 | 26 | [tool.poetry.dev-dependencies] 27 | 28 | 29 | [build-system] 30 | requires = ["poetry-core>=1.0.0"] 31 | build-backend = "poetry.core.masonry.api" 32 | -------------------------------------------------------------------------------- /substack/__init__.py: -------------------------------------------------------------------------------- 1 | """A library that provides a Python interface to the Substack API.""" 2 | 3 | __author__ = "Paolo Mazza" 4 | __email__ = "mazzapaolo2019@gmail.com" 5 | __license__ = "MIT License" 6 | __version__ = "1.0" 7 | __url__ = "https://github.com/ma2za/python-substack" 8 | __download_url__ = "https://pypi.python.org/pypi/python-substack" 9 | __description__ = "A Python wrapper around the Substack API" 10 | 11 | from .api import Api 12 | -------------------------------------------------------------------------------- /substack/api.py: -------------------------------------------------------------------------------- 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 urljoin 13 | 14 | import requests 15 | 16 | from substack.exceptions import SubstackAPIException, SubstackRequestException 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | __all__ = ["Api"] 21 | 22 | 23 | class Api: 24 | """ 25 | 26 | A python interface into the Substack API 27 | 28 | """ 29 | 30 | def __init__( 31 | self, 32 | email=None, 33 | password=None, 34 | cookies_path=None, 35 | base_url=None, 36 | publication_url=None, 37 | debug=False, 38 | ): 39 | """ 40 | 41 | To create an instance of the substack.Api class: 42 | >>> import substack 43 | >>> api = substack.Api(email="substack email", password="substack password") 44 | 45 | Args: 46 | email: 47 | password: 48 | cookies_path 49 | To re-use your session without logging in each time, you can save your cookies to a json file and 50 | then load them in the next session. 51 | Make sure to re-save your cookies, as they do update over time. 52 | base_url: 53 | The base URL to use to contact the Substack API. 54 | Defaults to https://substack.com/api/v1. 55 | """ 56 | self.base_url = base_url or "https://substack.com/api/v1" 57 | 58 | if debug: 59 | logging.basicConfig() 60 | logging.getLogger().setLevel(logging.DEBUG) 61 | 62 | self._session = requests.Session() 63 | 64 | # Load cookies from file if provided 65 | # Helps with Captcha errors by reusing cookies from "local" auth, then switching to running code in the cloud 66 | if cookies_path is not None: 67 | with open(cookies_path) as f: 68 | cookies = json.load(f) 69 | self._session.cookies.update(cookies) 70 | 71 | elif email is not None and password is not None: 72 | self.login(email, password) 73 | else: 74 | raise ValueError( 75 | "Must provide email and password or cookies_path to authenticate." 76 | ) 77 | 78 | user_publication = None 79 | # if the user provided a publication url, then use that 80 | if publication_url: 81 | import re 82 | 83 | # Regular expression to extract subdomain name 84 | match = re.search(r"https://(.*).substack.com", publication_url.lower()) 85 | subdomain = match.group(1) if match else None 86 | 87 | user_publications = self.get_user_publications() 88 | # search through publications to find the publication with the matching subdomain 89 | for publication in user_publications: 90 | if publication["subdomain"] == subdomain: 91 | # set the current publication to the users publication 92 | user_publication = publication 93 | break 94 | else: 95 | # get the users primary publication 96 | user_publication = self.get_user_primary_publication() 97 | 98 | # set the current publication to the users primary publication 99 | self.change_publication(user_publication) 100 | 101 | def login(self, email, password) -> dict: 102 | """ 103 | 104 | Login to the substack account. 105 | 106 | Args: 107 | email: substack account email 108 | password: substack account password 109 | """ 110 | 111 | response = self._session.post( 112 | f"{self.base_url}/login", 113 | json={ 114 | "captcha_response": None, 115 | "email": email, 116 | "for_pub": "", 117 | "password": password, 118 | "redirect": "/", 119 | }, 120 | ) 121 | 122 | return Api._handle_response(response=response) 123 | 124 | def signin_for_pub(self, publication): 125 | """ 126 | Complete the signin process 127 | """ 128 | response = self._session.get( 129 | f"https://substack.com/sign-in?redirect=%2F&for_pub={publication['subdomain']}", 130 | ) 131 | try: 132 | output = Api._handle_response(response=response) 133 | except SubstackRequestException as ex: 134 | output = {} 135 | return output 136 | 137 | def change_publication(self, publication): 138 | """ 139 | Change the publication URL 140 | """ 141 | self.publication_url = urljoin(publication["publication_url"], "api/v1") 142 | 143 | # sign-in to the publication 144 | self.signin_for_pub(publication) 145 | 146 | def export_cookies(self, path: str = "cookies.json"): 147 | """ 148 | Export cookies to a json file. 149 | Args: 150 | path: path to the json file 151 | """ 152 | cookies = self._session.cookies.get_dict() 153 | with open(path, "w") as f: 154 | json.dump(cookies, f) 155 | 156 | @staticmethod 157 | def _handle_response(response: requests.Response): 158 | """ 159 | 160 | Internal helper for handling API responses from the Substack server. 161 | Raises the appropriate exceptions when necessary; otherwise, returns the 162 | response. 163 | 164 | """ 165 | 166 | if not (200 <= response.status_code < 300): 167 | raise SubstackAPIException(response.status_code, response.text) 168 | try: 169 | return response.json() 170 | except ValueError: 171 | raise SubstackRequestException("Invalid Response: %s" % response.text) 172 | 173 | def get_user_id(self): 174 | """ 175 | 176 | Returns: 177 | 178 | """ 179 | profile = self.get_user_profile() 180 | user_id = profile["id"] 181 | 182 | return user_id 183 | 184 | @staticmethod 185 | def get_publication_url(publication: dict) -> str: 186 | """ 187 | Gets the publication url 188 | 189 | Args: 190 | publication: 191 | """ 192 | custom_domain = publication["custom_domain"] 193 | if not custom_domain: 194 | publication_url = f"https://{publication['subdomain']}.substack.com" 195 | else: 196 | publication_url = f"https://{custom_domain}" 197 | 198 | return publication_url 199 | 200 | def get_user_primary_publication(self): 201 | """ 202 | Gets the users primary publication 203 | """ 204 | 205 | profile = self.get_user_profile() 206 | primary_publication = profile["primaryPublication"] 207 | primary_publication["publication_url"] = self.get_publication_url( 208 | primary_publication 209 | ) 210 | 211 | return primary_publication 212 | 213 | def get_user_publications(self): 214 | """ 215 | Gets the users publications 216 | """ 217 | 218 | profile = self.get_user_profile() 219 | 220 | # Loop through users "publicationUsers" list, and return a list 221 | # of dictionaries of "name", and "subdomain", and "id" 222 | user_publications = [] 223 | for publication in profile["publicationUsers"]: 224 | pub = publication["publication"] 225 | pub["publication_url"] = self.get_publication_url(pub) 226 | user_publications.append(pub) 227 | 228 | return user_publications 229 | 230 | def get_user_profile(self): 231 | """ 232 | Gets the users profile 233 | """ 234 | response = self._session.get(f"{self.base_url}/user/profile/self") 235 | 236 | return Api._handle_response(response=response) 237 | 238 | def get_user_settings(self): 239 | """ 240 | Get list of users. 241 | 242 | Returns: 243 | 244 | """ 245 | response = self._session.get(f"{self.base_url}/settings") 246 | 247 | return Api._handle_response(response=response) 248 | 249 | def get_publication_users(self): 250 | """ 251 | Get list of users. 252 | 253 | Returns: 254 | 255 | """ 256 | response = self._session.get(f"{self.publication_url}/publication/users") 257 | 258 | return Api._handle_response(response=response) 259 | 260 | def get_publication_subscriber_count(self): 261 | 262 | """ 263 | Get subscriber count. 264 | 265 | Returns: 266 | 267 | """ 268 | response = self._session.get( 269 | f"{self.publication_url}/publication_launch_checklist" 270 | ) 271 | 272 | return Api._handle_response(response=response)["subscriberCount"] 273 | 274 | def get_published_posts( 275 | self, offset=0, limit=25, order_by="post_date", order_direction="desc" 276 | ): 277 | """ 278 | Get list of published posts for the publication. 279 | """ 280 | response = self._session.get( 281 | f"{self.publication_url}/post_management/published", 282 | params={ 283 | "offset": offset, 284 | "limit": limit, 285 | "order_by": order_by, 286 | "order_direction": order_direction, 287 | }, 288 | ) 289 | 290 | return Api._handle_response(response=response) 291 | 292 | def get_posts(self) -> dict: 293 | """ 294 | 295 | Returns: 296 | 297 | """ 298 | response = self._session.get(f"{self.base_url}/reader/posts") 299 | 300 | return Api._handle_response(response=response) 301 | 302 | def get_drafts(self, filter=None, offset=None, limit=None): 303 | """ 304 | 305 | Args: 306 | filter: 307 | offset: 308 | limit: 309 | 310 | Returns: 311 | 312 | """ 313 | response = self._session.get( 314 | f"{self.publication_url}/drafts", 315 | params={"filter": filter, "offset": offset, "limit": limit}, 316 | ) 317 | return Api._handle_response(response=response) 318 | 319 | def get_draft(self, draft_id): 320 | """ 321 | Gets a draft given it's id. 322 | 323 | """ 324 | response = self._session.get(f"{self.publication_url}/drafts/{draft_id}") 325 | return Api._handle_response(response=response) 326 | 327 | def delete_draft(self, draft_id): 328 | """ 329 | 330 | Args: 331 | draft_id: 332 | 333 | Returns: 334 | 335 | """ 336 | response = self._session.delete(f"{self.publication_url}/drafts/{draft_id}") 337 | return Api._handle_response(response=response) 338 | 339 | def post_draft(self, body) -> dict: 340 | """ 341 | 342 | Args: 343 | body: 344 | 345 | Returns: 346 | 347 | """ 348 | response = self._session.post(f"{self.publication_url}/drafts", json=body) 349 | return Api._handle_response(response=response) 350 | 351 | def put_draft(self, draft, **kwargs) -> dict: 352 | """ 353 | 354 | Args: 355 | draft: 356 | **kwargs: 357 | 358 | Returns: 359 | 360 | """ 361 | response = self._session.put( 362 | f"{self.publication_url}/drafts/{draft}", 363 | json=kwargs, 364 | ) 365 | return Api._handle_response(response=response) 366 | 367 | def prepublish_draft(self, draft) -> dict: 368 | """ 369 | 370 | Args: 371 | draft: draft id 372 | 373 | Returns: 374 | 375 | """ 376 | 377 | response = self._session.get( 378 | f"{self.publication_url}/drafts/{draft}/prepublish" 379 | ) 380 | return Api._handle_response(response=response) 381 | 382 | def publish_draft( 383 | self, draft, send: bool = True, share_automatically: bool = False 384 | ) -> dict: 385 | """ 386 | 387 | Args: 388 | draft: draft id 389 | send: 390 | share_automatically: 391 | 392 | Returns: 393 | 394 | """ 395 | response = self._session.post( 396 | f"{self.publication_url}/drafts/{draft}/publish", 397 | json={"send": send, "share_automatically": share_automatically}, 398 | ) 399 | return Api._handle_response(response=response) 400 | 401 | def schedule_draft(self, draft, draft_datetime: datetime) -> dict: 402 | """ 403 | 404 | Args: 405 | draft: draft id 406 | draft_datetime: datetime to schedule the draft 407 | 408 | Returns: 409 | 410 | """ 411 | response = self._session.post( 412 | f"{self.publication_url}/drafts/{draft}/schedule", 413 | json={"post_date": draft_datetime.isoformat()}, 414 | ) 415 | return Api._handle_response(response=response) 416 | 417 | def unschedule_draft(self, draft) -> dict: 418 | """ 419 | 420 | Args: 421 | draft: draft id 422 | 423 | Returns: 424 | 425 | """ 426 | response = self._session.post( 427 | f"{self.publication_url}/drafts/{draft}/schedule", json={"post_date": None} 428 | ) 429 | return Api._handle_response(response=response) 430 | 431 | def get_image(self, image: str): 432 | """ 433 | 434 | This method generates a new substack link that contains the image. 435 | 436 | Args: 437 | image: filepath or original url of image. 438 | 439 | Returns: 440 | 441 | """ 442 | if os.path.exists(image): 443 | with open(image, "rb") as file: 444 | image = b"data:image/jpeg;base64," + base64.b64encode(file.read()) 445 | 446 | response = self._session.post( 447 | f"{self.publication_url}/image", 448 | data={"image": image}, 449 | ) 450 | return Api._handle_response(response=response) 451 | 452 | def get_categories(self): 453 | """ 454 | 455 | Retrieve list of all available categories. 456 | 457 | Returns: 458 | 459 | """ 460 | response = self._session.get(f"{self.base_url}/categories") 461 | return Api._handle_response(response=response) 462 | 463 | def get_category(self, category_id, category_type, page): 464 | """ 465 | 466 | Args: 467 | category_id: 468 | category_type: 469 | page: 470 | 471 | Returns: 472 | 473 | """ 474 | response = self._session.get( 475 | f"{self.base_url}/category/public/{category_id}/{category_type}", 476 | params={"page": page}, 477 | ) 478 | return Api._handle_response(response=response) 479 | 480 | def get_single_category(self, category_id, category_type, page=None, limit=None): 481 | """ 482 | 483 | Args: 484 | category_id: 485 | category_type: paid or all 486 | page: by default substack retrieves only the first 25 publications in the category. If this is left None, 487 | then all pages will be retrieved. The page size is 25 publications. 488 | limit: 489 | Returns: 490 | 491 | """ 492 | if page is not None: 493 | output = self.get_category(category_id, category_type, page) 494 | else: 495 | publications = [] 496 | page = 0 497 | while True: 498 | page_output = self.get_category(category_id, category_type, page) 499 | publications.extend(page_output.get("publications", [])) 500 | if ( 501 | limit is not None and limit <= len(publications) 502 | ) or not page_output.get("more", False): 503 | publications = publications[:limit] 504 | break 505 | page += 1 506 | output = { 507 | "publications": publications, 508 | "more": page_output.get("more", False), 509 | } 510 | return output 511 | 512 | def delete_all_drafts(self): 513 | """ 514 | 515 | Returns: 516 | 517 | """ 518 | response = None 519 | while True: 520 | drafts = self.get_drafts(filter="draft", limit=10, offset=0) 521 | if len(drafts) == 0: 522 | break 523 | for draft in drafts: 524 | response = self.delete_draft(draft.get("id")) 525 | return response 526 | 527 | def get_sections(self): 528 | """ 529 | Get a list of the sections of your publication. 530 | 531 | TODO: this is hacky but I cannot find another place where to get the sections. 532 | Returns: 533 | 534 | """ 535 | response = self._session.get( 536 | f"{self.publication_url}/subscriptions", 537 | ) 538 | content = Api._handle_response(response=response) 539 | sections = [ 540 | p.get("sections") 541 | for p in content.get("publications") 542 | if p.get("hostname") in self.publication_url 543 | ] 544 | return sections[0] 545 | 546 | def publication_embed(self, url): 547 | """ 548 | 549 | Args: 550 | url: 551 | 552 | Returns: 553 | 554 | """ 555 | return self.call("/publication/embed", "GET", url=url) 556 | 557 | def call(self, endpoint, method, **params): 558 | """ 559 | 560 | Args: 561 | endpoint: 562 | method: 563 | **params: 564 | 565 | Returns: 566 | 567 | """ 568 | response = self._session.request( 569 | method=method, 570 | url=f"{self.publication_url}/{endpoint}", 571 | params=params, 572 | ) 573 | return Api._handle_response(response=response) 574 | -------------------------------------------------------------------------------- /substack/exceptions.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | class SubstackAPIException(Exception): 5 | def __init__(self, status_code, text): 6 | try: 7 | json_res = json.loads(text) 8 | except ValueError: 9 | self.message = f"Invalid JSON error message from Substack: {text}" 10 | else: 11 | self.message = ", ".join( 12 | list( 13 | map(lambda error: error.get("msg", ""), json_res.get("errors", [])) 14 | ) 15 | ) 16 | self.message = self.message or json_res.get("error", "") 17 | self.status_code = status_code 18 | 19 | def __str__(self): 20 | return f"APIError(code={self.status_code}): {self.message}" 21 | 22 | 23 | class SubstackRequestException(Exception): 24 | def __init__(self, message): 25 | self.message = message 26 | 27 | def __str__(self): 28 | return f"SubstackRequestException: {self.message}" 29 | 30 | 31 | class SectionNotExistsException(SubstackRequestException): 32 | pass 33 | -------------------------------------------------------------------------------- /substack/post.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Post Utilities 4 | 5 | """ 6 | 7 | import json 8 | from typing import Dict 9 | 10 | __all__ = ["Post"] 11 | 12 | from substack.exceptions import SectionNotExistsException 13 | 14 | 15 | class Post: 16 | """ 17 | 18 | Post utility class 19 | 20 | """ 21 | 22 | def __init__( 23 | self, 24 | title: str, 25 | subtitle: str, 26 | user_id, 27 | audience: str = None, 28 | write_comment_permissions: str = None, 29 | ): 30 | """ 31 | 32 | Args: 33 | title: 34 | subtitle: 35 | user_id: 36 | audience: possible values: everyone, only_paid, founding, only_free 37 | write_comment_permissions: none, only_paid, everyone (this field is a mess) 38 | """ 39 | self.draft_title = title 40 | self.draft_subtitle = subtitle 41 | self.draft_body = {"type": "doc", "content": []} 42 | self.draft_bylines = [{"id": int(user_id), "is_guest": False}] 43 | self.audience = audience if audience is not None else "everyone" 44 | self.draft_section_id = None 45 | self.section_chosen = True 46 | 47 | # TODO better understand the possible values and combinations with audience 48 | if write_comment_permissions is not None: 49 | self.write_comment_permissions = write_comment_permissions 50 | else: 51 | self.write_comment_permissions = self.audience 52 | 53 | def set_section(self, name: str, sections: list): 54 | """ 55 | 56 | Args: 57 | name: 58 | sections: 59 | 60 | Returns: 61 | 62 | """ 63 | section = [s for s in sections if s.get("name") == name] 64 | if len(section) != 1: 65 | raise SectionNotExistsException(name) 66 | section = section[0] 67 | self.draft_section_id = section.get("id") 68 | 69 | def add(self, item: Dict): 70 | """ 71 | 72 | Add item to draft body. 73 | 74 | Args: 75 | item: 76 | 77 | Returns: 78 | 79 | """ 80 | 81 | self.draft_body["content"] = self.draft_body.get("content", []) + [ 82 | {"type": item.get("type")} 83 | ] 84 | content = item.get("content") 85 | if item.get("type") == "captionedImage": 86 | self.captioned_image(**item) 87 | elif item.get("type") == "embeddedPublication": 88 | self.draft_body["content"][-1]["attrs"] = item.get("url") 89 | elif item.get("type") == "youtube2": 90 | self.youtube(item.get("src")) 91 | elif item.get("type") == "subscribeWidget": 92 | self.subscribe_with_caption(item.get("message")) 93 | else: 94 | if content is not None: 95 | self.add_complex_text(content) 96 | 97 | if item.get("type") == "heading": 98 | self.attrs(item.get("level", 1)) 99 | 100 | marks = item.get("marks") 101 | if marks is not None: 102 | self.marks(marks) 103 | 104 | return self 105 | 106 | def paragraph(self, content=None): 107 | """ 108 | 109 | Args: 110 | content: 111 | 112 | Returns: 113 | 114 | """ 115 | item = {"type": "paragraph"} 116 | if content is not None: 117 | item["content"] = content 118 | return self.add(item) 119 | 120 | def heading(self, content=None, level: int = 1): 121 | """ 122 | 123 | Args: 124 | content: 125 | level: 126 | 127 | Returns: 128 | 129 | """ 130 | 131 | item = {"type": "heading"} 132 | if content is not None: 133 | item["content"] = content 134 | item["level"] = level 135 | return self.add(item) 136 | 137 | def horizontal_rule(self): 138 | """ 139 | 140 | Returns: 141 | 142 | """ 143 | return self.add({"type": "horizontal_rule"}) 144 | 145 | def attrs(self, level): 146 | """ 147 | 148 | Args: 149 | level: 150 | 151 | Returns: 152 | 153 | """ 154 | content_attrs = self.draft_body["content"][-1].get("attrs", {}) 155 | content_attrs.update({"level": level}) 156 | self.draft_body["content"][-1]["attrs"] = content_attrs 157 | return self 158 | 159 | def captioned_image( 160 | self, 161 | src: str, 162 | fullscreen: bool = False, 163 | imageSize: str = "normal", 164 | height: int = 819, 165 | width: int = 1456, 166 | resizeWidth: int = 728, 167 | bytes: str = None, 168 | alt: str = None, 169 | title: str = None, 170 | type: str = None, 171 | href: str = None, 172 | belowTheFold: bool = False, 173 | internalRedirect: str = None, 174 | ): 175 | """ 176 | 177 | Add image to body. 178 | 179 | Args: 180 | bytes: 181 | alt: 182 | title: 183 | type: 184 | href: 185 | belowTheFold: 186 | internalRedirect: 187 | src: 188 | fullscreen: 189 | imageSize: 190 | height: 191 | width: 192 | resizeWidth: 193 | """ 194 | 195 | content = self.draft_body["content"][-1].get("content", []) 196 | content += [ 197 | { 198 | "type": "image2", 199 | "attrs": { 200 | "src": src, 201 | "fullscreen": fullscreen, 202 | "imageSize": imageSize, 203 | "height": height, 204 | "width": width, 205 | "resizeWidth": resizeWidth, 206 | "bytes": bytes, 207 | "alt": alt, 208 | "title": title, 209 | "type": type, 210 | "href": href, 211 | "belowTheFold": belowTheFold, 212 | "internalRedirect": internalRedirect, 213 | }, 214 | } 215 | ] 216 | self.draft_body["content"][-1]["content"] = content 217 | return self 218 | 219 | def text(self, value: str): 220 | """ 221 | 222 | Add text to the last paragraph. 223 | 224 | Args: 225 | value: Text to add to paragraph. 226 | 227 | Returns: 228 | 229 | """ 230 | content = self.draft_body["content"][-1].get("content", []) 231 | content += [{"type": "text", "text": value}] 232 | self.draft_body["content"][-1]["content"] = content 233 | return self 234 | 235 | def add_complex_text(self, text): 236 | """ 237 | 238 | Args: 239 | text: 240 | """ 241 | if isinstance(text, str): 242 | self.text(text) 243 | else: 244 | for chunk in text: 245 | if chunk: 246 | self.text(chunk.get("content")).marks(chunk.get("marks", [])) 247 | 248 | def marks(self, marks): 249 | """ 250 | 251 | Args: 252 | marks: 253 | 254 | Returns: 255 | 256 | """ 257 | content = self.draft_body["content"][-1].get("content", [])[-1] 258 | content_marks = content.get("marks", []) 259 | for mark in marks: 260 | new_mark = {"type": mark.get("type")} 261 | if mark.get("type") == "link": 262 | href = mark.get("href") 263 | new_mark.update({"attrs": {"href": href}}) 264 | content_marks.append(new_mark) 265 | content["marks"] = content_marks 266 | return self 267 | 268 | def remove_last_paragraph(self): 269 | """Remove last paragraph""" 270 | del self.draft_body.get("content")[-1] 271 | 272 | def get_draft(self): 273 | """ 274 | 275 | Returns: 276 | 277 | """ 278 | out = vars(self) 279 | out["draft_body"] = json.dumps(out["draft_body"]) 280 | return out 281 | 282 | def subscribe_with_caption(self, message: str = None): 283 | """ 284 | 285 | Add subscribe widget with caption 286 | 287 | Args: 288 | message: 289 | 290 | Returns: 291 | 292 | """ 293 | 294 | if message is None: 295 | message = """Thanks for reading this newsletter! 296 | Subscribe for free to receive new posts and support my work.""" 297 | 298 | subscribe = self.draft_body["content"][-1] 299 | subscribe["attrs"] = { 300 | "url": "%%checkout_url%%", 301 | "text": "Subscribe", 302 | "language": "en", 303 | } 304 | subscribe["content"] = [ 305 | { 306 | "type": "ctaCaption", 307 | "content": [ 308 | { 309 | "type": "text", 310 | "text": message, 311 | } 312 | ], 313 | } 314 | ] 315 | return self 316 | 317 | def youtube(self, value: str): 318 | """ 319 | 320 | Add youtube video to post. 321 | 322 | Args: 323 | value: youtube url 324 | 325 | Returns: 326 | 327 | """ 328 | content_attrs = self.draft_body["content"][-1].get("attrs", {}) 329 | content_attrs.update({"videoId": value}) 330 | self.draft_body["content"][-1]["attrs"] = content_attrs 331 | return self 332 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma2za/python-substack/d79f1ca067bc3fea1ee70e742e034ffb8d3daf37/tests/__init__.py -------------------------------------------------------------------------------- /tests/substack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma2za/python-substack/d79f1ca067bc3fea1ee70e742e034ffb8d3daf37/tests/substack/__init__.py -------------------------------------------------------------------------------- /tests/substack/test_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from dotenv import load_dotenv 5 | 6 | from substack import Api 7 | from substack.exceptions import SubstackAPIException 8 | 9 | load_dotenv() 10 | 11 | 12 | class ApiTest(unittest.TestCase): 13 | def test_api_exception(self): 14 | with self.assertRaises(SubstackAPIException): 15 | Api(email="", password="") 16 | 17 | def test_login(self): 18 | api = Api( 19 | email=os.getenv("EMAIL"), 20 | password=os.getenv("PASSWORD"), 21 | ) 22 | self.assertIsNotNone(api) 23 | 24 | def test_get_posts(self): 25 | api = Api(email=os.getenv("EMAIL"), password=os.getenv("PASSWORD")) 26 | posts = api.get_posts() 27 | self.assertIsNotNone(posts) 28 | 29 | def test_get_drafts(self): 30 | api = Api( 31 | email=os.getenv("EMAIL"), 32 | password=os.getenv("PASSWORD"), 33 | ) 34 | drafts = api.get_drafts() 35 | self.assertIsNotNone(drafts) 36 | 37 | def test_post_draft(self): 38 | api = Api( 39 | email=os.getenv("EMAIL"), 40 | password=os.getenv("PASSWORD"), 41 | ) 42 | posted_draft = api.post_draft([{"id": os.getenv("USER_ID"), "is_guest": False}]) 43 | self.assertIsNotNone(posted_draft) 44 | 45 | def test_publication_users(self): 46 | api = Api( 47 | email=os.getenv("EMAIL"), 48 | password=os.getenv("PASSWORD"), 49 | ) 50 | users = api.get_publication_users() 51 | self.assertIsNotNone(users) 52 | 53 | def test_put_draft(self): 54 | api = Api( 55 | email=os.getenv("EMAIL"), 56 | password=os.getenv("PASSWORD"), 57 | ) 58 | posted_draft = api.put_draft("") 59 | self.assertIsNotNone(posted_draft) 60 | 61 | def test_get_categories(self): 62 | api = Api() 63 | categories = api.get_categories() 64 | self.assertIsNotNone(categories) 65 | 66 | def test_get_single_category(self): 67 | api = Api() 68 | category = api.get_single_category(4, "all", limit=100) 69 | self.assertIsNotNone(category) 70 | --------------------------------------------------------------------------------