-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from hogier:develop
Develop
- Loading branch information
Showing
10 changed files
with
373 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -127,3 +127,5 @@ dmypy.json | |
|
||
# Pyre type checker | ||
.pyre/ | ||
|
||
.idea/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
# python-substack | ||
Substack API python implementation | ||
# Welcome to Python Substack | ||
|
||
Updated |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import os | ||
|
||
from dotenv import load_dotenv | ||
|
||
from substack import Api | ||
|
||
load_dotenv() | ||
|
||
content = "" | ||
title = "" | ||
subtitle = "" | ||
|
||
api = Api( | ||
email=os.getenv("EMAIL"), | ||
password=os.getenv("PASSWORD"), | ||
publication_url=os.getenv("PUBLICATION_URL"), | ||
) | ||
|
||
body = f'{{"type":"doc","content": {content}}}' | ||
|
||
draft = api.post_draft( | ||
[{"id": os.getenv("USER_ID"), "is_guest": False}], | ||
title=title, | ||
subtitle=subtitle, | ||
body=body, | ||
) | ||
|
||
api.prepublish_draft(draft.get("id")) | ||
|
||
api.publish_draft(draft.get("id")) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
[tool.poetry] | ||
name = "python-substack" | ||
version = "0.0.2" | ||
description = "A Python wrapper around the Substack API." | ||
authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"] | ||
license = "MIT" | ||
packages = [ | ||
{ include = "substack" } | ||
] | ||
|
||
readme = "README.md" | ||
|
||
repository = "https://github.com/hogier/python-substack" | ||
homepage = "https://github.com/hogier/python-substack" | ||
|
||
keywords = ["substack"] | ||
|
||
[tool.poetry.dependencies] | ||
python = "^3.8" | ||
python-dotenv = "^0.20.0" | ||
requests = "^2.28.1" | ||
|
||
[tool.poetry.dev-dependencies] | ||
|
||
|
||
[build-system] | ||
requires = ["poetry-core>=1.0.0"] | ||
build-backend = "poetry.core.masonry.api" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
"""A library that provides a Python interface to the Substack API.""" | ||
|
||
__author__ = "Paolo Mazza" | ||
__email__ = "mazzapaolo2019@gmail.com" | ||
__license__ = "MIT License" | ||
__version__ = "1.0" | ||
__url__ = "https://github.com/hogier/python-substack" | ||
__download_url__ = "https://pypi.python.org/pypi/python-substack" | ||
__description__ = "A Python wrapper around the Substack API" | ||
|
||
from .api import Api |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,207 @@ | ||
import logging | ||
|
||
import requests | ||
|
||
from substack.exceptions import SubstackAPIException, SubstackRequestException | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class Api: | ||
""" | ||
A python interface into the Substack API | ||
""" | ||
|
||
def __init__( | ||
self, | ||
email: str, | ||
password: str, | ||
base_url: str | None = None, | ||
publication_url: str | None = None, | ||
debug: bool = False, | ||
): | ||
""" | ||
To create an instance of the substack.Api class: | ||
>>> import substack | ||
>>> api = substack.Api(email="substack email", password="substack password") | ||
Args: | ||
email: | ||
password: | ||
base_url: | ||
The base URL to use to contact the Substack API. | ||
Defaults to https://substack.com/api/v1. | ||
""" | ||
self.base_url = base_url or "https://substack.com/api/v1" | ||
self.publication_url = publication_url | ||
|
||
if debug: | ||
logging.basicConfig() | ||
logging.getLogger().setLevel(logging.DEBUG) | ||
|
||
self._init_session(email, password) | ||
|
||
def login(self, email: str, password: str) -> dict: | ||
""" | ||
Args: | ||
email: | ||
password: | ||
""" | ||
|
||
response = self._session.post( | ||
f"{self.base_url}/login", | ||
json={ | ||
"captcha_response": None, | ||
"email": email, | ||
"for_pub": "", | ||
"password": password, | ||
"redirect": "/", | ||
}, | ||
) | ||
return Api._handle_response(response=response) | ||
|
||
def _init_session(self, email, password): | ||
self._session = requests.Session() | ||
|
||
self.login(email, password) | ||
|
||
@staticmethod | ||
def _handle_response(response: requests.Response): | ||
""" | ||
Internal helper for handling API responses from the Substack server. | ||
Raises the appropriate exceptions when necessary; otherwise, returns the | ||
response. | ||
""" | ||
|
||
if not (200 <= response.status_code < 300): | ||
raise SubstackAPIException(response.status_code, response.text) | ||
try: | ||
return response.json() | ||
except ValueError: | ||
raise SubstackRequestException("Invalid Response: %s" % response.text) | ||
|
||
def get_publication_users(self): | ||
""" | ||
:return: | ||
""" | ||
response = self._session.get(f"{self.publication_url}/publication/users") | ||
|
||
return Api._handle_response(response=response) | ||
|
||
def get_posts(self) -> dict: | ||
""" | ||
:return: | ||
""" | ||
response = self._session.get(f"{self.base_url}/reader/posts") | ||
|
||
return Api._handle_response(response=response) | ||
|
||
def get_drafts(self, filter: str = None, offset: int = None, limit: int = None): | ||
response = self._session.get( | ||
f"{self.publication_url}/drafts", | ||
params={"filter": filter, "offset": offset, "limit": limit}, | ||
) | ||
return Api._handle_response(response=response) | ||
|
||
def post_draft( | ||
self, | ||
draft_bylines: list, | ||
title: str = None, | ||
subtitle: str = None, | ||
body: str = None, | ||
) -> dict: | ||
""" | ||
Args: | ||
draft_bylines: | ||
title: | ||
subtitle: | ||
body: | ||
Returns: | ||
""" | ||
response = self._session.post( | ||
f"{self.publication_url}/drafts", | ||
json={ | ||
"draft_bylines": draft_bylines, | ||
"draft_title": title, | ||
"draft_subtitle": subtitle, | ||
"draft_body": body, | ||
}, | ||
) | ||
return Api._handle_response(response=response) | ||
|
||
def put_draft( | ||
self, | ||
draft: str, | ||
title: str = None, | ||
subtitle: str = None, | ||
body: str = None, | ||
cover_image: str = None, | ||
) -> dict: | ||
""" | ||
Args: | ||
draft: draft id | ||
title: | ||
subtitle: | ||
body: | ||
cover_image: | ||
Returns: | ||
""" | ||
|
||
response = self._session.put( | ||
f"{self.publication_url}/drafts/{draft}", | ||
json={ | ||
"draft_title": title, | ||
"draft_subtitle": subtitle, | ||
"draft_body": body, | ||
"cover_image": cover_image, | ||
}, | ||
) | ||
return Api._handle_response(response=response) | ||
|
||
def prepublish_draft(self, draft: str) -> dict: | ||
""" | ||
Args: | ||
draft: draft id | ||
Returns: | ||
""" | ||
|
||
response = self._session.get( | ||
f"{self.publication_url}/drafts/{draft}/prepublish" | ||
) | ||
return Api._handle_response(response=response) | ||
|
||
def publish_draft( | ||
self, draft: str, send: bool = True, share_automatically: bool = False | ||
) -> dict: | ||
""" | ||
Args: | ||
draft: draft id | ||
send: | ||
share_automatically: | ||
Returns: | ||
""" | ||
response = self._session.post( | ||
f"{self.publication_url}/drafts/{draft}/publish", | ||
json={"send": send, "share_automatically": share_automatically}, | ||
) | ||
return Api._handle_response(response=response) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
import json | ||
|
||
|
||
class SubstackAPIException(Exception): | ||
def __init__(self, status_code, text): | ||
try: | ||
json_res = json.loads(text) | ||
except ValueError: | ||
self.message = f"Invalid JSON error message from Substack: {text}" | ||
else: | ||
self.message = ", ".join( | ||
list( | ||
map(lambda error: error.get("msg", ""), json_res.get("errors", [])) | ||
) | ||
) | ||
self.message = self.message or json_res.get("error", "") | ||
self.status_code = status_code | ||
|
||
def __str__(self): | ||
return f"APIError(code={self.status_code}): {self.message}" | ||
|
||
|
||
class SubstackRequestException(Exception): | ||
def __init__(self, message): | ||
self.message = message | ||
|
||
def __str__(self): | ||
return f"SubstackRequestException: {self.message}" |
Empty file.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import os | ||
import unittest | ||
|
||
from dotenv import load_dotenv | ||
|
||
from substack import Api | ||
from substack.exceptions import SubstackAPIException | ||
|
||
load_dotenv() | ||
|
||
|
||
class ApiTest(unittest.TestCase): | ||
def test_api_exception(self): | ||
with self.assertRaises(SubstackAPIException): | ||
Api(email="", password="") | ||
|
||
def test_login(self): | ||
api = Api( | ||
email=os.getenv("EMAIL"), | ||
password=os.getenv("PASSWORD"), | ||
publication_url=os.getenv("PUBLICATION_URL"), | ||
) | ||
self.assertIsNotNone(api) | ||
|
||
def test_get_posts(self): | ||
api = Api(email=os.getenv("EMAIL"), password=os.getenv("PASSWORD")) | ||
posts = api.get_posts() | ||
self.assertIsNotNone(posts) | ||
|
||
def test_get_drafts(self): | ||
api = Api( | ||
email=os.getenv("EMAIL"), | ||
password=os.getenv("PASSWORD"), | ||
publication_url=os.getenv("PUBLICATION_URL"), | ||
) | ||
drafts = api.get_drafts() | ||
self.assertIsNotNone(drafts) | ||
|
||
def test_post_draft(self): | ||
api = Api( | ||
email=os.getenv("EMAIL"), | ||
password=os.getenv("PASSWORD"), | ||
publication_url=os.getenv("PUBLICATION_URL"), | ||
) | ||
posted_draft = api.post_draft([{"id": os.getenv("USER_ID"), "is_guest": False}]) | ||
self.assertIsNotNone(posted_draft) | ||
|
||
def test_publication_users(self): | ||
api = Api( | ||
email=os.getenv("EMAIL"), | ||
password=os.getenv("PASSWORD"), | ||
publication_url=os.getenv("PUBLICATION_URL"), | ||
) | ||
users = api.get_publication_users() | ||
self.assertIsNotNone(users) | ||
|
||
def test_put_draft(self): | ||
api = Api( | ||
email=os.getenv("EMAIL"), | ||
password=os.getenv("PASSWORD"), | ||
publication_url=os.getenv("PUBLICATION_URL"), | ||
) | ||
posted_draft = api.put_draft("") | ||
self.assertIsNotNone(posted_draft) |