├── .gitignore ├── LICENSE ├── README.md ├── poetry.lock ├── pydevto.egg-info ├── PKG-INFO ├── SOURCES.txt ├── dependency_links.txt ├── requires.txt └── top_level.txt ├── pydevto ├── __init__.py ├── markdown_converter.py ├── markdownify_license.txt └── pydevto.py ├── pyproject.toml └── tests ├── __init__.py └── test_pydevto.py /.gitignore: -------------------------------------------------------------------------------- 1 | # files 2 | .DS_Store 3 | test.py 4 | 5 | # folders 6 | .idea 7 | __pycache__ 8 | pytest_cache 9 | how_long.egg-info 10 | pip-wheel-metadata 11 | dist 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Loftie Ellis 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 | # PyDevTo 2 | 3 | Unofficial dev.to api for python. 4 | 5 | ### Features 6 | * Implements all endpoints from https://docs.dev.to/api/ 7 | * Implements a few other api endpoints not documented but available in the source, such as users and follow_suggestions 8 | * Includes a helper method to convert html to dev.to specific markdown, including support for dev.to specific embeds such as YouTube. 9 | 10 | 11 | ## Installation 12 | 13 | Use the package manager [pip](https://pip.pypa.io/en/stable/) to install pydevto. 14 | 15 | ```bash 16 | pip install pydevto 17 | ``` 18 | 19 | ## Usage 20 | 21 | Make sure you have an api key to use the authenticated endpoints. You can get your key from https://dev.to/settings/account 22 | (You can use pydevto without an api key for some functions, such as the public articles) 23 | 24 | ```python 25 | import pydevto 26 | api = pydevto.PyDevTo(api_key='MY_KEY') 27 | api.articles() # returns list of your own published articles 28 | ``` 29 | 30 | ## Methods 31 | ```python 32 | import pydevto 33 | api = pydevto.PyDevTo(api_key='MY_KEY') 34 | api.public_articles(page=None, tag=None, username=None, state=None, top=None) # Return list of public (published) articles 35 | api.public_article(id) # Return a single public (published) article given its id 36 | api.articles(page=None, per_page=None, state="published") # Return a list of user articles 37 | api.create_article(...) # Create an article 38 | api.update_article(id, ...) # Update an article 39 | api.user(id=None, username=None) # Return user information 40 | api.follow_suggestions(page=None) # Return list of follow suggestions 41 | api.tags(page=None) # Return list of tags 42 | api.webhooks() # Return list of webhooks 43 | api.webhook(id) # Return single webhook with id 44 | api.create_webhook(source, target_url, events) # Create a new webhook 45 | api.delete_webhook(id) # Delete a webhook with id 46 | ``` 47 | 48 | ## Html to Markdown 49 | PyDevTo contains a helper function to convert html to dev.to specific markdown (https://dev.to/p/editor_guide) 50 | It supports images with captions using the HTML figcaption tag, and converts embeds such as YouTube to dev.to specific liquid tags. 51 | ```python 52 | >>> import pydevto 53 | >>> pydevto.html_to_markdown('

Heading>> '# Heading\n\n' 55 | >>> pydevto.html_to_markdown('') 56 | >>> '\n{% youtube kmjiUVEMvI4 %}\n' 57 | ``` 58 | 59 | ## Known issues 60 | * The tags property does not currently work correctly when creating/updating an article. There is an open issue report on dev.to for this. 61 | * The html to markdown only caters for a subset of embeds (YouTube, Twitter, repl.it, soundcloud and a few more), more will be added over time. 62 | 63 | ## Contributing 64 | Pull requests and issue reports are welcome. 65 | 66 | ## License 67 | [MIT](https://choosealicense.com/licenses/mit/) 68 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | category = "dev" 3 | description = "Atomic file writes." 4 | name = "atomicwrites" 5 | optional = false 6 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 7 | version = "1.3.0" 8 | 9 | [[package]] 10 | category = "dev" 11 | description = "Classes Without Boilerplate" 12 | name = "attrs" 13 | optional = false 14 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 15 | version = "19.3.0" 16 | 17 | [[package]] 18 | category = "main" 19 | description = "Screen-scraping library" 20 | name = "beautifulsoup4" 21 | optional = false 22 | python-versions = "*" 23 | version = "4.8.1" 24 | 25 | [package.dependencies] 26 | soupsieve = ">=1.2" 27 | 28 | [[package]] 29 | category = "main" 30 | description = "Python package for providing Mozilla's CA Bundle." 31 | name = "certifi" 32 | optional = false 33 | python-versions = "*" 34 | version = "2019.9.11" 35 | 36 | [[package]] 37 | category = "main" 38 | description = "Universal encoding detector for Python 2 and 3" 39 | name = "chardet" 40 | optional = false 41 | python-versions = "*" 42 | version = "3.0.4" 43 | 44 | [[package]] 45 | category = "dev" 46 | description = "Cross-platform colored terminal text." 47 | marker = "sys_platform == \"win32\"" 48 | name = "colorama" 49 | optional = false 50 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 51 | version = "0.4.1" 52 | 53 | [[package]] 54 | category = "main" 55 | description = "Internationalized Domain Names in Applications (IDNA)" 56 | name = "idna" 57 | optional = false 58 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 59 | version = "2.8" 60 | 61 | [[package]] 62 | category = "dev" 63 | description = "Read metadata from Python packages" 64 | marker = "python_version < \"3.8\"" 65 | name = "importlib-metadata" 66 | optional = false 67 | python-versions = ">=2.7,!=3.0,!=3.1,!=3.2,!=3.3" 68 | version = "0.23" 69 | 70 | [package.dependencies] 71 | zipp = ">=0.5" 72 | 73 | [[package]] 74 | category = "dev" 75 | description = "More routines for operating on iterables, beyond itertools" 76 | name = "more-itertools" 77 | optional = false 78 | python-versions = ">=3.4" 79 | version = "7.2.0" 80 | 81 | [[package]] 82 | category = "dev" 83 | description = "plugin and hook calling mechanisms for python" 84 | name = "pluggy" 85 | optional = false 86 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 87 | version = "0.13.0" 88 | 89 | [package.dependencies] 90 | [package.dependencies.importlib-metadata] 91 | python = "<3.8" 92 | version = ">=0.12" 93 | 94 | [[package]] 95 | category = "dev" 96 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 97 | name = "py" 98 | optional = false 99 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 100 | version = "1.8.0" 101 | 102 | [[package]] 103 | category = "dev" 104 | description = "pytest: simple powerful testing with Python" 105 | name = "pytest" 106 | optional = false 107 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 108 | version = "3.10.1" 109 | 110 | [package.dependencies] 111 | atomicwrites = ">=1.0" 112 | attrs = ">=17.4.0" 113 | colorama = "*" 114 | more-itertools = ">=4.0.0" 115 | pluggy = ">=0.7" 116 | py = ">=1.5.0" 117 | setuptools = "*" 118 | six = ">=1.10.0" 119 | 120 | [[package]] 121 | category = "main" 122 | description = "Python HTTP for Humans." 123 | name = "requests" 124 | optional = false 125 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 126 | version = "2.22.0" 127 | 128 | [package.dependencies] 129 | certifi = ">=2017.4.17" 130 | chardet = ">=3.0.2,<3.1.0" 131 | idna = ">=2.5,<2.9" 132 | urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" 133 | 134 | [[package]] 135 | category = "main" 136 | description = "Python 2 and 3 compatibility utilities" 137 | name = "six" 138 | optional = false 139 | python-versions = ">=2.6, !=3.0.*, !=3.1.*" 140 | version = "1.12.0" 141 | 142 | [[package]] 143 | category = "main" 144 | description = "A modern CSS selector implementation for Beautiful Soup." 145 | name = "soupsieve" 146 | optional = false 147 | python-versions = "*" 148 | version = "1.9.4" 149 | 150 | [[package]] 151 | category = "main" 152 | description = "HTTP library with thread-safe connection pooling, file post, and more." 153 | name = "urllib3" 154 | optional = false 155 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" 156 | version = "1.25.6" 157 | 158 | [[package]] 159 | category = "dev" 160 | description = "Backport of pathlib-compatible object wrapper for zip files" 161 | marker = "python_version < \"3.8\"" 162 | name = "zipp" 163 | optional = false 164 | python-versions = ">=2.7" 165 | version = "0.6.0" 166 | 167 | [package.dependencies] 168 | more-itertools = "*" 169 | 170 | [metadata] 171 | content-hash = "3830f2ec9500abfc74d668704848cef59aed7b2e11b9068282250c624b699fcb" 172 | python-versions = "^3.6" 173 | 174 | [metadata.hashes] 175 | atomicwrites = ["03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", "75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"] 176 | attrs = ["08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"] 177 | beautifulsoup4 = ["5279c36b4b2ec2cb4298d723791467e3000e5384a43ea0cdf5d45207c7e97169", "6135db2ba678168c07950f9a16c4031822c6f4aec75a65e0a97bc5ca09789931", "dcdef580e18a76d54002088602eba453eec38ebbcafafeaabd8cab12b6155d57"] 178 | certifi = ["e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", "fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"] 179 | chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] 180 | colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] 181 | idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"] 182 | importlib-metadata = ["aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", "d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"] 183 | more-itertools = ["409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", "92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"] 184 | pluggy = ["0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", "fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"] 185 | py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"] 186 | pytest = ["3f193df1cfe1d1609d4c583838bea3d532b18d6160fd3f55c9447fdca30848ec", "e246cf173c01169b9617fc07264b7b1316e78d7a650055235d6d897bc80d9660"] 187 | requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] 188 | six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] 189 | soupsieve = ["605f89ad5fdbfefe30cdc293303665eff2d188865d4dbe4eb510bba1edfbfce3", "b91d676b330a0ebd5b21719cb6e9b57c57d433671f65b9c28dd3461d9a1ed0b6"] 190 | urllib3 = ["3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398", "9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86"] 191 | zipp = ["3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", "f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"] 192 | -------------------------------------------------------------------------------- /pydevto.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 1.2 2 | Name: pydevto 3 | Version: 0.1.3 4 | Summary: Unofficial dev.to api for python 5 | Home-page: https://github.com/lpellis/pydevto 6 | Author: 'Loftie 7 | Author-email: lpellis@gmail.com 8 | License: UNKNOWN 9 | Description: # PyDevTo 10 | 11 | Unofficial dev.to api for python. 12 | 13 | ### Features 14 | * Implements all endpoints from https://docs.dev.to/api/ 15 | * Implements a few other api endpoints not documented but available in the source, such as users and follow_suggestions 16 | * Includes a helper method to convert html to dev.to specific markdown, including support for dev.to specific embeds such as YouTube. 17 | 18 | 19 | ## Installation 20 | 21 | Use the package manager [pip](https://pip.pypa.io/en/stable/) to install pydevto. 22 | 23 | ```bash 24 | pip install pydevto 25 | ``` 26 | 27 | ## Usage 28 | 29 | Make sure you have an api key to use the authenticated endpoints. You can get your key from https://dev.to/settings/account 30 | (You can use pydevto without an api key for some functions, such as the public articles) 31 | 32 | ```python 33 | import pydevto 34 | api = pydevto.PyDevTo(api_key='MY_KEY') 35 | api.articles() # returns list of your own published articles 36 | ``` 37 | 38 | ## Methods 39 | ```python 40 | import pydevto 41 | api = pydevto.PyDevTo(api_key='MY_KEY') 42 | api.public_articles(page=None, tag=None, username=None, state=None, top=None) # Return list of public (published) articles 43 | api.public_article(id) # Return a single public (published) article given its id 44 | api.articles(page=None, per_page=None, state="published") # Return a list of user articles 45 | api.create_article(...) # Create an article 46 | api.update_article(id, ...) # Update an article 47 | api.user(id=None, username=None) # Return user information 48 | api.follow_suggestions(page=None) # Return list of follow suggestions 49 | api.tags(page=None) # Return list of tags 50 | api.webhooks() # Return list of webhooks 51 | api.webhook(id) # Return single webhook with id 52 | api.create_webhook(source, target_url, events) # Create a new webhook 53 | api.delete_webhook(id) # Delete a webhook with id 54 | ``` 55 | 56 | ## Html to Markdown 57 | PyDevTo contains a helper function to convert html to dev.to specific markdown (https://dev.to/p/editor_guide) 58 | It supports images with captions using the HTML figcaption tag, and converts embeds such as YouTube to dev.to specific liquid tags. 59 | ```python 60 | >>> import pydevto 61 | >>> pydevto.html_to_markdown('

Heading>> '# Heading\n\n' 63 | >>> pydevto.html_to_markdown('') 64 | >>> '\n{% youtube kmjiUVEMvI4 %}\n' 65 | ``` 66 | 67 | ## Known issues 68 | * The tags property does not currently work correctly when creating/updating an article. There is an open issue report on dev.to for this. 69 | * The html to markdown only caters for a subset of embeds (YouTube, Twitter, repl.it, soundcloud and a few more), more will be added over time. 70 | 71 | ## Contributing 72 | Pull requests and issue reports are welcome. 73 | 74 | ## License 75 | [MIT](https://choosealicense.com/licenses/mit/) 76 | 77 | Platform: UNKNOWN 78 | Requires-Python: >=3.6,<4.0 79 | -------------------------------------------------------------------------------- /pydevto.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- 1 | README.md 2 | setup.py 3 | pydevto/__init__.py 4 | pydevto/markdown_converter.py 5 | pydevto/markdownify_license.txt 6 | pydevto/pydevto.py 7 | pydevto.egg-info/PKG-INFO 8 | pydevto.egg-info/SOURCES.txt 9 | pydevto.egg-info/dependency_links.txt 10 | pydevto.egg-info/requires.txt 11 | pydevto.egg-info/top_level.txt -------------------------------------------------------------------------------- /pydevto.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pydevto.egg-info/requires.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4<5.0,>=4.8 2 | requests<3.0,>=2.22 3 | six<2.0,>=1.12 4 | -------------------------------------------------------------------------------- /pydevto.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | pydevto 2 | -------------------------------------------------------------------------------- /pydevto/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.0' 2 | 3 | from pydevto.pydevto import PyDevTo 4 | from pydevto.markdown_converter import html_to_markdown 5 | -------------------------------------------------------------------------------- /pydevto/markdown_converter.py: -------------------------------------------------------------------------------- 1 | import urllib.parse as urlparse 2 | 3 | from bs4 import BeautifulSoup, NavigableString 4 | import re 5 | import six 6 | 7 | 8 | convert_heading_re = re.compile(r"convert_h(\d+)") 9 | line_beginning_re = re.compile(r"^", re.MULTILINE) 10 | whitespace_re = re.compile(r"[\r\n\s\t ]+") 11 | FRAGMENT_ID = "__MARKDOWNIFY_WRAPPER__" 12 | wrapped = '
%%s
' % FRAGMENT_ID 13 | 14 | 15 | # Heading styles 16 | ATX = "atx" 17 | ATX_CLOSED = "atx_closed" 18 | UNDERLINED = "underlined" 19 | SETEXT = UNDERLINED 20 | 21 | 22 | def escape(text): 23 | if not text: 24 | return "" 25 | return text.replace("_", r"\_") 26 | 27 | 28 | def _todict(obj): 29 | return dict((k, getattr(obj, k)) for k in dir(obj) if not k.startswith("_")) 30 | 31 | 32 | class MarkdownConverter(object): 33 | """ 34 | This class is based on on markdownify (https://github.com/matthewwithanm/python-markdownify) but extended to 35 | handle dev.to specific markdown and embeds 36 | """ 37 | class DefaultOptions: 38 | strip = None 39 | convert = None 40 | autolinks = True 41 | heading_style = UNDERLINED 42 | bullets = "*+-" # An iterable of bullet types. 43 | 44 | class Options(DefaultOptions): 45 | pass 46 | 47 | def __init__(self, **options): 48 | # Create an options dictionary. Use DefaultOptions as a base so that 49 | # it doesn't have to be extended. 50 | self.options = _todict(self.DefaultOptions) 51 | self.options.update(_todict(self.Options)) 52 | self.options.update(options) 53 | if self.options["strip"] is not None and self.options["convert"] is not None: 54 | raise ValueError( 55 | "You may specify either tags to strip or tags to" 56 | " convert, but not both." 57 | ) 58 | 59 | def convert(self, html): 60 | # We want to take advantage of the html5 parsing, but we don't actually 61 | # want a full document. Therefore, we'll mark our fragment with an id, 62 | # create the document, and extract the element with the id. 63 | html = wrapped % html 64 | soup = BeautifulSoup(html, "html.parser") 65 | return self.process_tag(soup.find(id=FRAGMENT_ID), children_only=True) 66 | 67 | def process_tag(self, node, children_only=False): 68 | text = "" 69 | 70 | # Convert the children first 71 | for el in node.children: 72 | if isinstance(el, NavigableString): 73 | text += self.process_text(six.text_type(el)) 74 | else: 75 | text += self.process_tag(el) 76 | 77 | if not children_only: 78 | convert_fn = getattr(self, "convert_%s" % node.name, None) 79 | if convert_fn and self.should_convert_tag(node.name): 80 | text = convert_fn(node, text) 81 | # else: 82 | # text = text + node.name 83 | 84 | return text 85 | 86 | def process_text(self, text): 87 | return escape(whitespace_re.sub(" ", text or "")) 88 | 89 | def __getattr__(self, attr): 90 | # Handle headings 91 | m = convert_heading_re.match(attr) 92 | if m: 93 | n = int(m.group(1)) 94 | 95 | def convert_tag(el, text): 96 | return self.convert_hn(n, el, text) 97 | 98 | convert_tag.__name__ = "convert_h%s" % n 99 | setattr(self, convert_tag.__name__, convert_tag) 100 | return convert_tag 101 | 102 | raise AttributeError(attr) 103 | 104 | def should_convert_tag(self, tag): 105 | tag = tag.lower() 106 | strip = self.options["strip"] 107 | convert = self.options["convert"] 108 | if strip is not None: 109 | return tag not in strip 110 | elif convert is not None: 111 | return tag in convert 112 | else: 113 | return True 114 | 115 | def indent(self, text, level): 116 | return line_beginning_re.sub("\t" * level, text) if text else "" 117 | 118 | def underline(self, text, pad_char): 119 | text = (text or "").rstrip() 120 | return "%s\n%s\n\n" % (text, pad_char * len(text)) if text else "" 121 | 122 | def convert_a(self, el, text): 123 | href = el.get("href") 124 | title = el.get("title") 125 | if self.options["autolinks"] and text == href and not title: 126 | # Shortcut syntax 127 | return "<%s>" % href 128 | title_part = ' "%s"' % title.replace('"', r"\"") if title else "" 129 | return "[%s](%s%s)" % (text or "", href, title_part) if href else text or "" 130 | 131 | def convert_b(self, el, text): 132 | return self.convert_strong(el, text) 133 | 134 | def convert_figcaption(self, el, text): 135 | return "\n
" + text + "
\n" 136 | 137 | def convert_blockquote(self, el, text): 138 | 139 | # handle twitter embeds 140 | if el.get("class") and el.get("class")[0] == "twitter-tweet": 141 | for entry in el.find_all("a"): 142 | if entry.get("href").startswith("https://twitter.com/"): 143 | return self.pydevto_embed(entry.get("href")) 144 | 145 | return "\n" + line_beginning_re.sub("> ", text) if text else "" 146 | 147 | def convert_br(self, el, text): 148 | return " \n" 149 | 150 | def remove_scheme(self, url): 151 | return ( 152 | url.replace("https://www.", "//") 153 | .replace("https://", "//") 154 | .replace("http://www.", "//") 155 | .replace("http://", "//") 156 | ) 157 | 158 | def convert_iframe(self, el, text): 159 | src = self.remove_scheme(el.attrs.get("src")) 160 | if src.startswith("//cdn.unfurl.dev/embed?"): 161 | src = urlparse.unquote(src) 162 | query = urlparse.urlsplit(src).query 163 | params = urlparse.parse_qs(query) 164 | if params.get("url"): 165 | embed = params.get("url")[0] 166 | return self.pydevto_embed(embed) 167 | 168 | if src.startswith("//cdn.embedly.com"): 169 | src = urlparse.unquote(src) 170 | query = urlparse.urlsplit(src).query 171 | params = urlparse.parse_qs(query) 172 | if params.get("src"): 173 | embed = params.get("src")[0] 174 | return self.pydevto_embed(embed) 175 | 176 | return self.pydevto_embed(el.attrs.get("src")) 177 | 178 | def pydevto_embed(self, url): 179 | original_url = url 180 | url = self.remove_scheme(url) 181 | 182 | if url.startswith("//twitter.com"): 183 | q = urlparse.urlsplit(url) 184 | return "\n{% twitter " + q.path.split("/")[-1] + " %}\n" 185 | 186 | if url.startswith("//youtube.com"): 187 | q = urlparse.urlsplit(url) 188 | if q.path.startswith("/embed/"): 189 | return "\n{% youtube " + q.path.replace("/embed/", "") + " %}\n" 190 | v = urlparse.parse_qs(q.query)["v"][0] 191 | return "\n{% youtube " + v + " %}\n" 192 | 193 | if url.startswith("//codepen.io"): 194 | return "\n{% codepen " + original_url + " %}\n" 195 | 196 | if url.startswith("//soundcloud.com"): 197 | return "\n{% soundcloud " + original_url + " %}\n" 198 | 199 | if url.startswith("//github.com"): 200 | return "\n{% github " + original_url + " %}\n" 201 | 202 | if url.startswith("//instagram.com"): 203 | q = urlparse.urlsplit(url) 204 | return ( 205 | "\n{% instagram " + q.path.replace("/p", "").replace("/", "") + " %}\n" 206 | ) 207 | 208 | if url.startswith("//repl.it"): 209 | q = urlparse.urlsplit(url) 210 | return "\n{% replit " + q.path[1:] + " %}\n" 211 | 212 | return "\n" + original_url + "\n" 213 | 214 | def convert_em(self, el, text): 215 | return "*%s*" % text if text else "" 216 | 217 | def convert_hn(self, n, el, text): 218 | style = self.options["heading_style"] 219 | text = text.rstrip() 220 | if style == UNDERLINED and n <= 2: 221 | line = "=" if n == 1 else "-" 222 | return self.underline(text, line) 223 | hashes = "#" * n 224 | if style == ATX_CLOSED: 225 | return "%s %s %s\n\n" % (hashes, text, hashes) 226 | return "%s %s\n\n" % (hashes, text) 227 | 228 | def convert_i(self, el, text): 229 | return self.convert_em(el, text) 230 | 231 | def convert_list(self, el, text): 232 | nested = False 233 | while el: 234 | if el.name == "li": 235 | nested = True 236 | break 237 | el = el.parent 238 | if nested: 239 | text = "\n" + self.indent(text, 1) 240 | return "\n" + text + "\n" 241 | 242 | convert_ul = convert_list 243 | convert_ol = convert_list 244 | 245 | def convert_li(self, el, text): 246 | parent = el.parent 247 | if parent is not None and parent.name == "ol": 248 | bullet = "%s." % (parent.index(el) + 1) 249 | else: 250 | depth = -1 251 | while el: 252 | if el.name == "ul": 253 | depth += 1 254 | el = el.parent 255 | bullets = self.options["bullets"] 256 | bullet = bullets[depth % len(bullets)] 257 | return "%s %s\n" % (bullet, text or "") 258 | 259 | def convert_p(self, el, text): 260 | return "%s\n\n" % text if text else "" 261 | 262 | def convert_strong(self, el, text): 263 | return "**%s**" % text if text else "" 264 | 265 | def convert_img(self, el, text): 266 | alt = el.attrs.get("alt", None) or "" 267 | src = el.attrs.get("src", None) or "" 268 | title = el.attrs.get("title", None) or "" 269 | title_part = ' "%s"' % title.replace('"', r"\"") if title else "" 270 | return "![%s](%s%s)" % (alt, src, title_part) 271 | 272 | 273 | def html_to_markdown(html): 274 | html = html.replace("
", "

---

") 275 | return MarkdownConverter(heading_style="atx").convert(html) 276 | -------------------------------------------------------------------------------- /pydevto/markdownify_license.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2012-2018 Matthew Tretter 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 | -------------------------------------------------------------------------------- /pydevto/pydevto.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | class PyDevTo: 5 | def __init__(self, api_key=None, timeout=None): 6 | """ 7 | 8 | :param api_key: Your dev.to api key (https://dev.to/settings/account) 9 | :param timeout: Timeout period for http requests 10 | """ 11 | self.api_key = api_key 12 | self.timeout = timeout 13 | 14 | def public_articles(self, page=None, tag=None, username=None, state=None, top=None): 15 | """Return a list of public (published) articles 16 | 17 | :param page: pagination page 18 | :param tag: articles that contain the requested tag. 19 | :param username: articles belonging to a User or Organization ordered by descending published_at 20 | :param state: "fresh" or "rising". check which articles are fresh or rising. 21 | :param top: (int) most popular articles in the last N days 22 | :return: 23 | """ 24 | return requests.get( 25 | "https://dev.to/api/articles", 26 | params={ 27 | "page": page, 28 | "tag": tag, 29 | "username": username, 30 | "state": state, 31 | "top": top, 32 | }, 33 | ).json() 34 | 35 | def public_article(self, id): 36 | """Return a single public (published) article given its id 37 | 38 | :param id: id of the article 39 | :return: article 40 | """ 41 | return requests.get( 42 | "https://dev.to/api/articles/{id}".format(id=id), timeout=self.timeout 43 | ).json() 44 | 45 | def articles(self, page=None, per_page=None, state="published"): 46 | """Return a list of user articles 47 | 48 | :param page: pagination page 49 | :param per_page: page size 50 | :param state: "published", "unpublished" or "all 51 | :return: list of articles 52 | """ 53 | url = "https://dev.to/api/articles/me" 54 | if state == "published": 55 | url = "https://dev.to/api/articles/me" 56 | elif state == "unpublished": 57 | url = "https://dev.to/api/articles/me/unpublished" 58 | elif state == "all": 59 | url = "https://dev.to/api/articles/me/all" 60 | 61 | return requests.get( 62 | url, 63 | params={"page": page, "per_page": per_page}, 64 | headers={"api-key": self.api_key}, 65 | timeout=self.timeout, 66 | ).json() 67 | 68 | def create_article( 69 | self, 70 | title, 71 | body_markdown="", # must default to empty string instead of None otherwise dev.to raises error on edit 72 | published=None, 73 | series=None, 74 | main_image=None, 75 | canonical_url=None, 76 | description=None, 77 | tags=None, 78 | organization_id=None, 79 | ): 80 | """Create an article 81 | 82 | :param title: Title 83 | :param body_markdown: Article Markdown content 84 | :param published: True to create published article, false otherwise 85 | :param series: Article series name 86 | :param main_image: Main image (or cover image) 87 | :param canonical_url: Canonical Url 88 | :param description: Article Description 89 | :param tags: List of article tags 90 | :param organization_id: Organization id 91 | :return: newly created article 92 | """ 93 | url = "https://dev.to/api/articles" 94 | 95 | data = { 96 | "title": title, 97 | "body_markdown": body_markdown, 98 | "series": series, 99 | "published": published, 100 | "main_image": main_image, 101 | "canonical_url": canonical_url, 102 | "description": description, 103 | "tags": tags, 104 | "organization_id": organization_id, 105 | } 106 | # remove None keys from dict 107 | data = {k: v for k, v in data.items() if v is not None} 108 | 109 | return requests.post( 110 | url, json=data, headers={"api-key": self.api_key}, timeout=self.timeout 111 | ).json() 112 | 113 | def update_article( 114 | self, 115 | id, 116 | title=None, 117 | body_markdown=None, 118 | published=None, 119 | series=None, 120 | main_image=None, 121 | canonical_url=None, 122 | description=None, 123 | tags=None, 124 | organization_id=None, 125 | ): 126 | """Update an article 127 | 128 | :param id: id of article to update 129 | :param title: Title 130 | :param body_markdown: Article Markdown content 131 | :param published: True to create published article, false otherwise 132 | :param series: Article series name 133 | :param main_image: Main image (or cover image) 134 | :param canonical_url: Canonical Url 135 | :param description: Article Description 136 | :param tags: List of article tags 137 | :param organization_id: Organization id 138 | :return: updated article 139 | """ 140 | url = "https://dev.to/api/articles/{id}".format(id=id) 141 | 142 | data = { 143 | "title": title, 144 | "body_markdown": body_markdown, 145 | "series": series, 146 | "published": published, 147 | "main_image": main_image, 148 | "canonical_url": canonical_url, 149 | "description": description, 150 | "tags": tags, 151 | "organization_id": organization_id, 152 | } 153 | # remove None keys from dict 154 | data = {k: v for k, v in data.items() if v is not None} 155 | 156 | return requests.put( 157 | url, json=data, headers={"api-key": self.api_key}, timeout=self.timeout 158 | ).json() 159 | 160 | def user(self, id=None, username=None): 161 | """Return user information 162 | 163 | If both id and username is None then information for user with the api_key (me) is returned 164 | 165 | :param id: (optional) id of user 166 | :param username: (optional) username of user 167 | :return: user object 168 | """ 169 | url = "https://dev.to/api/users/me" 170 | if id: 171 | url = "https://dev.to/api/users/{id}".format(id=id) 172 | elif username: 173 | url = "https://dev.to/api/users/by_username" 174 | 175 | return requests.get( 176 | url, 177 | params={"url": username}, 178 | headers={"api-key": self.api_key}, 179 | timeout=self.timeout, 180 | ).json() 181 | 182 | def follow_suggestions(self, page=None): 183 | """Return list of follow suggestions 184 | 185 | :param page: pagination page 186 | :return: list of follow suggestions 187 | """ 188 | return requests.get( 189 | "https://dev.to/api/users/?state=follow_suggestions", 190 | params={"page": page}, 191 | headers={"api-key": self.api_key}, 192 | timeout=self.timeout, 193 | ).json() 194 | 195 | def tags(self, page=None): 196 | """Return list of tags 197 | 198 | :param page: pagination page 199 | :return: 200 | """ 201 | return requests.get( 202 | "https://dev.to/api/tags", 203 | params={"page": page}, 204 | headers={"api-key": self.api_key}, 205 | timeout=self.timeout, 206 | ).json() 207 | 208 | def webhooks(self): 209 | """Return list of webhooks 210 | 211 | :return: list of webhooks 212 | """ 213 | return requests.get( 214 | "https://dev.to/api/webhooks", 215 | headers={"api-key": self.api_key}, 216 | timeout=self.timeout, 217 | ).json() 218 | 219 | def webhook(self, id): 220 | """Return single webhook with id 221 | 222 | :param id: id of webhook 223 | :return: webhook object 224 | """ 225 | return requests.get( 226 | "https://dev.to/api/webhooks/{id}".format(id=id), 227 | headers={"api-key": self.api_key}, 228 | timeout=self.timeout, 229 | ).json() 230 | 231 | def create_webhook(self, source, target_url, events): 232 | """Create a new webhook 233 | 234 | :param source: The name of the requester, eg. "DEV" 235 | :param target_url: Target Url 236 | :param events: List of event identifiers 237 | :return: 238 | """ 239 | return requests.post( 240 | "https://dev.to/api/webhooks", 241 | headers={"api-key": self.api_key}, 242 | json={"source": source, "target_url": target_url, "events": events}, 243 | timeout=self.timeout, 244 | ).json() 245 | 246 | def delete_webhook(self, id): 247 | """Delete a webhook with id 248 | 249 | :param id: id of webhook 250 | :return: 251 | """ 252 | return requests.delete( 253 | "https://dev.to/api/webhooks/{id}".format(id=id), 254 | headers={"api-key": self.api_key}, 255 | timeout=self.timeout, 256 | ).json() 257 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pydevto" 3 | version = "0.1.4" 4 | description = "Unofficial dev.to api for python" 5 | readme = "README.md" 6 | repository = "https://github.com/lpellis/pydevto" 7 | license = "MIT" 8 | authors = ["'Loftie "] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.6" 12 | beautifulsoup4 = "^4.8" 13 | six = "^1.12" 14 | requests = "^2.22" 15 | 16 | [tool.poetry.dev-dependencies] 17 | pytest = "^3.0" 18 | 19 | [build-system] 20 | requires = ["poetry>=0.12"] 21 | build-backend = "poetry.masonry.api" 22 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lpellis/pydevto/4c374759b6ae95846ee1648519809ac0e382cbbc/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_pydevto.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import pydevto 4 | from pydevto import __version__ 5 | 6 | 7 | def test_version(): 8 | assert __version__ == "0.1.0" 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "html,result", 13 | [ 14 | ("

heading

", "# heading\n\n"), 15 | ("

heading

", "## heading\n\n"), 16 | ("

heading

", "### heading\n\n"), 17 | ("

heading

", "#### heading\n\n"), 18 | ("
heading
", "##### heading\n\n"), 19 | ("
heading
", "###### heading\n\n"), 20 | ("strong", "**strong**"), 21 | ("bold", "**bold**"), 22 | ("em", "*em*"), 23 | ("
", "---\n\n"), 24 | ("italic", "*italic*"), 25 | ("
quote
", "\n> quote"), 26 | ("", "\n* item1\n* item2\n\n"), 27 | ], 28 | ) 29 | def test_html_to_markdown_basic(html, result): 30 | assert pydevto.html_to_markdown(html) == result 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "html,result", 35 | [ 36 | ("boldand italic", "**bold*and italic***"), 37 | ("italicbold", "*italic***bold**"), 38 | ], 39 | ) 40 | def test_html_to_markdown_nested(html, result): 41 | assert pydevto.html_to_markdown(html) == result 42 | 43 | 44 | @pytest.mark.parametrize( 45 | "html,result", 46 | [ 47 | ( 48 | '

example link

', 49 | "[example link](https://example.com)\n\n", 50 | ), 51 | ( 52 | "

example link

", 53 | "[example link](https://example.com)\n\n", 54 | ), 55 | ( 56 | '

example link

', 57 | "[example link](https://example.com)\n\n", 58 | ), 59 | ( 60 | '

example link

', 61 | '[example link](https://example.com "the title")\n\n', 62 | ), 63 | ( 64 | '

the link text!

', 65 | "[the link text!](http://test.com/x/d/?a=b&c=d)\n\n", 66 | ), 67 | ], 68 | ) 69 | def test_html_to_markdown_links(html, result): 70 | assert pydevto.html_to_markdown(html) == result 71 | 72 | 73 | @pytest.mark.parametrize( 74 | "html,result", 75 | [ 76 | ( 77 | '', 78 | "![](http://example.com/favicon.png)", 79 | ), 80 | ( 81 | 'alt image', 82 | "![alt image](http://example.com/favicon.png)", 83 | ), 84 | ( 85 | 'Small picture of a cat
small caption
', 86 | "![Small picture of a cat](http://placekitten.com/200/300)\n
small caption
\n", 87 | ), 88 | ], 89 | ) 90 | def test_html_to_markdown_images(html, result): 91 | assert pydevto.html_to_markdown(html) == result 92 | 93 | 94 | @pytest.mark.parametrize( 95 | "html,result", 96 | [ 97 | ( 98 | '', 99 | "\n{% youtube kmjiUVEMvI4 %}\n", 100 | ), 101 | ( 102 | """""", 103 | "\n{% replit @WigWog/Practice-Problem-8 %}\n", 104 | ), 105 | ( 106 | """ """, 107 | "\n{% twitter 1188230579646619649 %}\n ", 108 | ), 109 | ], 110 | ) 111 | def test_html_to_markdown_embeds(html, result): 112 | assert pydevto.html_to_markdown(html) == result 113 | 114 | @pytest.mark.parametrize( 115 | "html,result", 116 | [ 117 | ( 118 | '', 119 | "\nhttps://www.example.com/unknown/embed?id=2\n", 120 | ), 121 | ], 122 | ) 123 | def test_html_to_markdown_embeds_unknown(html, result): 124 | assert pydevto.html_to_markdown(html) == result 125 | 126 | @pytest.mark.parametrize( 127 | "html,result", 128 | [ 129 | ( 130 | '', 131 | "\n{% youtube l9nh1l8ZIJQ %}\n", 132 | ), 133 | ( 134 | """""", 135 | "\n{% twitter 1016476808638656521 %}\n", 136 | ), 137 | ( 138 | """""", 139 | "\n{% codepen https://codepen.io/twhite96/pen/XKqrJX %}\n", 140 | ), 141 | ( 142 | """""", 143 | "\n{% instagram BXgGcAUjM39 %}\n", 144 | ), 145 | ( 146 | """""", 147 | "\n{% soundcloud https://soundcloud.com/blanc_de_noir/sets/glitched-love %}\n", 148 | ), 149 | ], 150 | ) 151 | def test_html_to_markdown_embeds_unfurl(html, result): 152 | assert pydevto.html_to_markdown(html) == result 153 | 154 | @pytest.mark.parametrize( 155 | "html,result", 156 | [ 157 | ( 158 | """""", 159 | "\n{% youtube 7YpIumoM1Os %}\n", 160 | ), 161 | ], 162 | ) 163 | def test_html_to_markdown_embedly(html, result): 164 | assert pydevto.html_to_markdown(html) == result 165 | --------------------------------------------------------------------------------