Crossposting to Medium via Command Line
This post is heavily based on “Crossposting to DevTo via Command Line”. However, that is not a prerequisite reading.
Let’s see how to crosspost a local Jekyll-like blog post to Medium. First, we have the crosspost
function:
crosspost :: Text -> Text -> IO ()
= do
crosspost apiKey path -- ^ Medium API key.
-- ^ Filepath to the blog post to crosspost.
<- Data.ByteString.readFile . Data.Text.unpack $ path
f case parseYamlFrontmatter f of
-- ^ Parse the frontmatter (and content) of the blog post to crosspost.
Done postBS frontmatter -> do
-- ^ If the frontmatter (and content) was parsed successfully..
let opts =
defaults& Network.Wreq.auth ?~ Network.Wreq.oauth2Bearer (encodeUtf8 apiKey)
<- Network.Wreq.getWith opts "https://api.medium.com/v1/me"
r let id_ = r ^. responseBody . key "data" . key "id" . _String
-- ^ ..then get the user id associated with the API key and..
let post = decodeUtf8 postBS
let json = toJSON $ mkMediumPost path frontmatter post
-- ^ ..create the JSON body for the Medium endpoint and..
let opts2 =
defaults& Network.Wreq.auth ?~ Network.Wreq.oauth2Bearer (encodeUtf8 apiKey)
& Network.Wreq.header "Content-Type" .~ [encodeUtf8 "application/json; charset=utf-8"]
<-
r2
Network.Wreq.postWith-- ^ ..post the request to Medium to create the blog post.
opts"https://api.medium.com/v1/users/" <> Data.Text.unpack id_ <> "/posts")
(
jsonprint $ r2 ^? responseBody
->
e error $ show e
-- ^ ..else stop execution and display the error `e`.
To perform HTTP request we use Wreq which employs optics (e.g. .~
, ^.
). Of course, any other HTTP package would have been ok. Just wanted to have some fun.
The data sent to the Medium endpoint is represented by the MediumPost
type:
-- `MediumPost` is what we send to Medium.
data MediumPost = MediumPost
title :: Text,
{ tags :: [Text],
canonicalUrl :: Text,
publishStatus :: Text,
content :: Text,
contentFormat :: Text,
notifyFollowers :: Bool
}deriving (Show, Generic)
instance ToJSON MediumPost where
MediumPost {..} =
toJSON
object"title" .= title,
[ "tags" .= tags,
"canonicalUrl" .= canonicalUrl,
"publishStatus" .= publishStatus,
"content" .= content,
"contentFormat" .= contentFormat,
"notifyFollowers" .= notifyFollowers
]
-- `Front` is what we parse from the local blog post.
data Front = Front
title :: Text,
{ description :: Text,
tags :: [Text]
}deriving (Show, Generic, FromJSON)
mkMediumPost :: Text -> Front -> Text -> MediumPost
Front {..} post = MediumPost {..}
mkMediumPost path -- ^ Same as `{ title = title, description = description, tags = tags }`.
-- Enabled by {-# LANGUAGE RecordWildCards #-}.
where
= "draft"
publishStatus = fold ["Originally posted on", " ", "[odone.io](", canonicalUrl, ").\n\n---\n\n", post]
content = "markdown"
contentFormat = urlFor path
canonicalUrl = True
notifyFollowers
-- URL of the blog post on odone.io.
urlFor :: Text -> Text
= fold [base, "/", name, ".html"]
urlFor path where
= "https://odone.io/posts"
base = Data.Text.pack . System.FilePath.Posix.takeBaseName . Data.Text.unpack $ path name
We then use optparse-applicative to get the inputs needed from the command line. Its readme is awesome, so please refer to that to learn more.
main :: IO ()
= uncurry crosspost =<< execParser opts
main -- ^ Parses the command line input and returns a tuple (String, String).
-- ^ `uncurry` converts a function on two arguments to a function expecting a tuple.
where
=
opts
info<**> helper)
(parser
( fullDesc<> progDesc "Crossposts POST to Medium"
)
parser :: Options.Applicative.Parser (Text, Text)
=
parser
(,)<$> Options.Applicative.argument
str-- ^ The first mandatory argument is the API key to Medium.
"API_KEY"
( metavar <> help "API_KEY to post on Medium"
)<*> Options.Applicative.argument
str-- ^ The second mandatory argument is the path to the blog post to crosspost.
"POST"
( metavar <> help "Path to blog POST to crosspost"
)
With that in place, calling the script without the mandatory arguments we get:
./tomedium.hs
# Missing: API_KEY POST
#
# Usage: tomedium.hs API_KEY POST
# Crossposts POST to Medium
We can also call it with --help
to get a detailed explanation:
./tomedium.hs --help
# Usage: tomedium.hs API_KEY POST
# Crossposts POST to Medium
#
# Available options:
# API_KEY API_KEY to post on Medium
# POST Path to blog POST to crosspost
# -h,--help Show this help text
A proper call adds an unpublished blog post on Medium with all the following filled properly:
- title;
- description;
- tags;
- canonical_url (the URL of the post on odone.io);
- content.
The whole script is on GitHub.
The fact that the script is based on the one for DevTo and written in Haskell made my life really easy. Yet another great reason to write scripts using static strong types.