Back to blog
Blog

How To Build A Medium Backup App

Tony Spiro's avatar

Tony Spiro

March 06, 2017

cover image

Medium has become the de-facto platform for publishing online content.  With its friction-less UI and viral suggestion engine, it's not hard to understand why it's one of the most popular blogging services.  I personally enjoy using Medium to post content to the Cosmic Medium publication.  Even though I trust Medium to store all of my content, I wanted a way to save my Medium posts as a backup, or to use in any other future applications.  When I couldn't find an available backup application, I decided to build one.  In this article, I'm going to show you how to build a Medium backup application using Node.js and Cosmic.

TL;DR
Check out the full source code on GitHub.
Install the app in minutes on Cosmic.

Getting Started
First, we'll need to go over some limitations.  Medium makes an RSS feed available to retrieve posts from any personal account, publication or a custom domain but it only allows you to retrieve the last 10 posts. Read this help article from Medium to find out which URL structure to use. It can be https://medium.com/feed/@yourusername or if you have a custom domain https://customdomain.com/feed. If you find that you are only getting partial articles, you may need to go into your Medium account settings and make sure RSS Feed is set to "Full".

Planning the App
We want to be able to do a couple things with our Medium backup app:

1. Manually Import posts from any feed url to our Cosmic Bucket.
2. Add / Remove Cron Jobs which will automatically look for new posts and import them into our Cosmic Bucket.

Building the App
In your text editor of choice, start by adding a package.json file:

{
  "dependencies": {
    "async": "^2.1.4",
    "body-parser": "^1.15.2",
    "cosmicjs": "^2.35.0",
    "express": "^4.14.0",
    "hogan-express": "^0.5.2",
    "nodemon": "^1.11.0",
    "request": "^2.79.0",
    "slug": "^0.9.1",
    "xml2js": "^0.4.17"
  },
  "scripts": {
    "start": "node app.js",
    "development": "nodemon app.js -e js, views/html"
  }
}

Then run the following command:

npm install

Next create a new file titled app.js and add the following:

// app.js
var express = require('express')
var async = require('async')
var bodyParser = require('body-parser')
var app = express()
app.use(bodyParser.json())
var hogan = require('hogan-express')
app.engine('html', hogan)
app.set('port', (process.env.PORT || 3000))
app.use('/', express.static(__dirname + '/public/'))
// Config
var bucket_slug = process.env.COSMIC_BUCKET || 'medium-backup'
var config = {
  cron_interval: process.env.CRON_INTERVAL || 3600000,
  bucket: {
    slug: bucket_slug,
    read_key: process.env.COSMIC_READ_KEY || '',
    write_key: process.env.COSMIC_WRITE_KEY || ''
  },
  url: process.env.URL || 'http://localhost:' + app.get('port')
}
// Routes
require('./routes/index.js')(app, config)
require('./routes/import-posts.js')(app, config, async)
require('./routes/add-crons.js')(app, config, async)
require('./routes/delete-cron.js')(app, config)
app.listen(app.get('port'))

Notice we are using Express for our web framework, we have set our configuration to point to our Cosmic Bucket and added a few routes to handle our home page (index.js), our post import route and our routes to handle the cron adding and deleting.

Our index.js file is pretty simple.  Just add the following:

// index.js
module.exports = function(app, config) {
  app.get('/', function(req, res) {
    var Cosmic = require('cosmicjs')
    Cosmic.getObjectType(config, { type_slug: 'crons' }, function(err, response) {
      res.locals.crons = response.objects.all
      res.locals.bucket_slug = config.bucket.slug
      res.render('index.html')
    })
  })
}

Basically we are calling the Cosmic API to see if we have any crons saved, then rendering our index.html file located in the views folder. 

Import Posts Manually
Next, let's build the import posts functionality.  Create a file titled import-posts.js and add the following:

// import-posts.js
module.exports = function(app, config, async) {
  app.post('/import-posts', function(req, res) {
    var Cosmic = require('cosmicjs')
    var request = require('request')
    var slug = require('slug')
    var parseString = require('xml2js').parseString
    var feed_url = req.body.feed_url
    var bucket_slug = req.body.bucket_slug
    var cosmic_config = {
      bucket: {
        slug: bucket_slug,
        read_key: process.env.COSMIC_READ_KEY || '',
        write_key: process.env.COSMIC_WRITE_KEY || ''
      }
    }
    request(feed_url, function (error, response, body) {
      if (!error && response.statusCode == 200) {
        parseString(body, function (err, result) {
          var posts = result.rss.channel[0].item
          var posts_imported = []
          async.eachSeries(posts, (post, callback) => {
            var title = 'Post'
            if (post.title)
              title = post.title[0]
            var content, published_at, modified_at, categories, created_by, medium_link;
            if (post.description)
              content = post.description[0]
            if (post['content:encoded'])
              content = post['content:encoded'][0]
            if (post['pubDate'])
              published_at = post['pubDate'][0]
            if (post['atom:updated'])
              modified_at = post['atom:updated'][0]
            if (post['category'])
              categories = post['category']
            if (post['dc:creator'])
              created_by = post['dc:creator'][0]
            if (post['link'])
              medium_link = post['link'][0]
            // Test if object available
            Cosmic.getObject(cosmic_config, { slug: slug(title) }, function(err, response) {
              if (response && response.object) {
                // already added
                return callback()
              } else {
                var params = {
                  title: title,
                  slug: slug(title),
                  content: content,
                  type_slug: 'posts',
                  write_key: config.bucket.write_key,
                  metafields: [
                    {
                      key: 'published_at',
                      title: 'Published At',
                      value: published_at
                    },
                    {
                      key: 'modified_at',
                      title: 'Modified At',
                      value: modified_at
                    },
                    {
                      key: 'created_by',
                      title: 'Created By',
                      value: created_by
                    },
                    {
                      key: 'medium_link',
                      title: 'Medium Link',
                      value: medium_link
                    }
                  ]
                }
                if (categories) {
                  var tags = ''
                  categories.forEach(category => {
                    tags += category + ', '
                  })
                  params.metafields.push({
                    key: 'tags',
                    title: 'Tags',
                    value: tags
                  })
                }
                Cosmic.addObject(cosmic_config, params, function(err, response) {
                  if (response)
                    posts_imported.push(post)
                  callback()
                })
              }
            })
          }, () => {
            if (!posts_imported.length) {
              res.status(500).json({ error: 'There was an error with this request.' })
            }
            res.json({
              bucket_slug: config.bucket.slug,
              posts: posts_imported
            })
          })
        })
      } else {
        res.status(500).json({ error: 'feed_url' })
      }
    })
  })
}

This file does most of the work in our Medium backup app.  What's happening here is:
1. First, we post feed_url and bucket_slug.
2. The RSS feed is accessed with the request module and the data is parsed and converted to JSON for easy management.
3. Then we loop through all of the posts and check if the post already exists in our Cosmic Bucket.  If it exists, do nothing.  It it doesn't exist:
4. Create the object in our Cosmic Bucket

This saves the title, content (HTML also!), published date / time, author, Medium link and even tags to your Cosmic Bucket.

Import Posts Automatically
This is great for our manual import, next let's create the ability to automatically import my latest articles automatically on a timer.  Create a couple files titled add-crons.js and delete-cron.jsto add / remove our cron jobs:

// add-cron.js
module.exports = function(app, config, async) {
  app.post('/add-crons', function(req, res) {
    var Cosmic = require('cosmicjs')
    var slug = require('slug')
    var crons = req.body
    async.eachSeries(crons, (cron, callback) => {
      var params = {
        title: cron.title,
        slug: slug(cron.title),
        type_slug: 'crons',
        write_key: config.bucket.write_key,
        metafields: [
          {
            key: 'feed_url',
            title: 'Feed URL',
            value: cron.feed_url
          },
          {
            key: 'bucket_slug',
            title: 'Bucket Slug',
            value: cron.bucket_slug
          }
        ]
      }
      Cosmic.addObject(config, params, function(err, response) {
        callback()
      })
    }, () => {
      res.json({
        status: "success"
      })
    })
  })
}
// delete-cron.js
module.exports = function(app, config, async) {
  app.post('/delete-cron', function(req, res) {
    var Cosmic = require('cosmicjs')
    var slug = req.body.slug
    var params = {
      write_key: config.bucket.write_key,
      slug: slug
    }
    Cosmic.deleteObject(config, params, function(err, response) {
      res.json({
        status: "success"
      })
    })
  })
}

Next create a file titled crons.js and add the following:

// crons.js
module.exports = function(app, config, async) {
  var Cosmic = require('cosmicjs')
  var request = require('request')
  var locals = {}
  async.series([
    callback => {
      Cosmic.getObjectType(config, { type_slug: 'crons' }, function(err, response) {
        locals.crons = response.objects.all
        callback()
      })
    },
    callback => {
      if (locals.crons) {
        async.eachSeries(locals.crons, (cron, callbackEach) => {
          var feed_url = cron.metadata.feed_url
          var bucket_slug = cron.metadata.bucket_slug
          var params = {
            feed_url: feed_url,
            bucket_slug: bucket_slug 
          }
          var options = {
            url: config.url + '/import-posts',
            json: params
          }
          request.post(options, (err, httpResponse, body) => {
            if (err) {
              return console.error('upload failed:', err)
            }
            console.log('Successful!  Server responded with:', body)
            callbackEach()
          });
        })
      }
    }
  ])
}

What's happening is we check our Bucket for any Objects in the "Crons" Object Type.  If any are found, we loop through all of these and POST to the import-posts endpoint in our app and import the latest posts.

Next we will want to set this to run on a timer, change our app.js to look like the following (added the cron at the bottom):

// app.js
var express = require('express')
var async = require('async')
var bodyParser = require('body-parser')
var app = express()
app.use(bodyParser.json())
var hogan = require('hogan-express')
app.engine('html', hogan)
app.set('port', (process.env.PORT || 3000))
app.use('/', express.static(__dirname + '/public/'))
// Config
var bucket_slug = process.env.COSMIC_BUCKET || 'medium-backup'
var config = {
  cron_interval: process.env.CRON_INTERVAL || 3600000,
  bucket: {
    slug: bucket_slug,
    read_key: process.env.COSMIC_READ_KEY || '',
    write_key: process.env.COSMIC_WRITE_KEY || ''
  },
  url: process.env.URL || 'http://localhost:' + app.get('port')
}
// Routes
require('./routes/index.js')(app, config)
require('./routes/import-posts.js')(app, config, async)
require('./routes/add-crons.js')(app, config, async)
require('./routes/delete-cron.js')(app, config)
// Crons
var getCrons = require('./routes/crons.js')
setInterval(() => getCrons(app, config, async), config.cron_interval) // every 60 minutes
app.listen(app.get('port'))

And that's pretty much it for our processing files.  For the display and simple jQuery frontend check out the index.html file.

Conclusion
I hope you enjoyed this article on how to build a Medium backup app.  To begin backing up your Medium posts automatically, install this app on Cosmic in minutes.  Let me know if you have any questions about this app or Cosmic reach out to us on Twitter or join us in the Cosmic Slack channel.