DEV Community

Riccardo Odone
Riccardo Odone

Posted on • Edited on • Originally published at odone.io

Crossposting to Medium via Command Line

You can keep reading here or jump to my blog to get the full experience, including the wonderful pink, blue and white palette.


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 ()
crosspost apiKey path = do
--        ^ Medium API key.
--               ^ Filepath to the blog post to crosspost.
  f <- Data.ByteString.readFile . Data.Text.unpack $ path
  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)
      r <- Network.Wreq.getWith opts "https://api.medium.com/v1/me"
      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")
          json
      print $ r2 ^? responseBody
    e ->
      error $ show e
--    ^ ..else stop execution and display the error `e`.
Enter fullscreen mode Exit fullscreen mode

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
  toJSON MediumPost {..} =
    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
mkMediumPost path Front {..} post = MediumPost {..}
--                      ^ Same as `{ title = title, description = description, tags = tags }`.
--                        Enabled by {-# LANGUAGE RecordWildCards #-}.
  where
    publishStatus = "draft"
    content = fold ["Originally posted on", " ", "[odone.io](", canonicalUrl, ").\n\n---\n\n", post]
    contentFormat = "markdown"
    canonicalUrl = urlFor path
    notifyFollowers = True

-- URL of the blog post on odone.io.
urlFor :: Text -> Text
urlFor path = fold [base, "/", name, ".html"]
  where
    base = "https://odone.io/posts"
    name = Data.Text.pack . System.FilePath.Posix.takeBaseName . Data.Text.unpack $ path
Enter fullscreen mode Exit fullscreen mode

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 ()
main = uncurry crosspost =<< execParser opts
--                           ^ 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
        (parser <**> helper)
        ( 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.
      ( metavar "API_KEY"
          <> help "API_KEY to post on Medium"
      )
    <*> Options.Applicative.argument
      str
--    ^ The second mandatory argument is the path to the blog post to crosspost.
      ( metavar "POST"
          <> help "Path to blog POST to crosspost"
      )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.


Get the latest content via email from me personally. Reply with your thoughts. Let's learn from each other. Subscribe to my PinkLetter!

Top comments (0)