👻

Automate publishing tweets to Ghost with Make.com

On a whim last year, I started a newsletter called No More Hustleporn that was basically curated tweet threads. The idea was that there was a lot of great information on Twitter, if you knew the right people to follow. But a lot of my friends weren’t on Twitter, and even if they got on Twitter, due to the cold-start follow problem, they likely weren’t seeing a lot of interesting, relevant content on their timelines.

I started out by curating three tweet threads a week, manually putting the tweet threads through some tools that unroll it/turn it into a pdf, and then copy pasting that into Ghost. This was obviously pretty tedious, and after a while I hired someone else to do it. But they were expensive and after a while, I ran out of momentum.

It was a bit of a shame because engagement on the newsletter was actually really good - consistently over 50% open rates.

What was always lurking at the back of my mind was that there was a way to do this process so that the marginal effort it took me over just scrolling Twitter and liking tweets was minimal. I was just too lazy to do it.

Luckily, I had some free time at the end of 2022, and I spend a day looking into the best ways to automate No More Hustleporn, and finally did it! It actually gave me a ton of respect for the various no-code tools out there — the sheer number of integrations that Zapier and Make have built out is incredibly impressive, and I ended up being automate the newsletter from tagging a tweet to email in my subscribers’ inbox, with only a tiny bit of code.

  1. Use SaveToNotion to save the tweet thread in a Notion database

SaveToNotion is a tool that lets you reply @SaveToNotion to a Tweet thread and the tweet will be saved in a Notion database.

image
image
  1. Set up a Make.com scenario

Make.com is a Zapier competitor that lets you string together various API calls to common SaaS products like Airtable and Notion in a no-code fashion. It also lets you write custom modules for lightweight APIs. So for example, if you want to monitor a Notion database, and do something with those database records, on a certain schedule, this is exactly what Make.com is for!

A sequence in Make.com is called a “scenario”, and can be constructed in a drag and drop fashion:

image

To start off, I add a Notion module, for watching Notion database items

  1. Use NotionToHTML to convert notion items to HTML

Now that I have the tweets in a Notion database, how do I get them into a format that will be understand by Ghost, where ultimately the newsletter is published from?

I googled around for Notion to HTML converters and found a Node API that converts private Notion items to HTML. I forked the repo and deployed it on Render.

image

To connect this to the Notion database, I set up another module in Make.com. This was a generic HTTP module that would make a call to the https://notion-to-html.onrender.com endpoint. The input to this second module would be the output of the first module.

image
image
  1. Write some custom code to clean up the HTML

Now, the NotionToHtml Node API did a pretty good job at generating HTML from the Notion page, but it left behind some artifacts that I didn’t want, for example the profile images of each tweet author. I wrote a tiny bit of Python to do some cleanup:

from flask import Flask, request
from bs4 import BeautifulSoup
import re

app = Flask(__name__)

@app.route('/health')
def health():
  return 'OK'

@app.route('/parse-html', methods=['POST'])
def parse_html():

  html = request.data
  # Parse the HTML using BeautifulSoup
  soup = BeautifulSoup(html, 'html.parser')
  # Find all the img tags
  img_tags = soup.find_all('img')
  # Find all span tags with style exactly 'font-weight:600;font-style:;text-decoration:'
  bold_spans = soup.find_all('span', style="font-weight:600;font-style:;text-decoration:")
  print(bold_spans)
  # Find all the spans with style exactly "color:rgb(51, 126, 169);font-weight:600;font-style:italic;text-decoration:" that also contain a <a href> tag with "twitter" in the href
  other_bold_spans = soup.find_all('span', style="color:rgb(51, 126, 169);font-weight:600;font-style:italic;text-decoration:")
  for span in other_bold_spans:
    # Find the <a> tag with the href
    a_tag = span.find('a', href=lambda x: 'twitter' in x)
    if a_tag:
      print(a_tag)
  
  # Loop through the img tags
  for img in img_tags:
    # Get the alt attribute of the image
    alt_text = img['alt']
    # Remove the image from the HTML
    img.extract()
    if (alt_text):
      # Create a new paragraph tag
      p_tag = soup.new_tag('p')
      # Set the text of the paragraph tag to the alt text
      p_tag.string = alt_text
      # Add the alt text to the HTML
      soup.body.insert(-1, p_tag)
      # Add a line break to the HTML that is 5em
      line_break = soup.new_tag('br')
      line_break['style'] = 'line-height: 5em;'
      soup.body.insert(-1, line_break)

  # Loop through the bold spans and remove them from the HTML
  for span in bold_spans:
    span.extract()

  # loop through all the iframes and turn them into img tags that are 600px by 400px
  iframes = soup.find_all('iframe')
  for iframe in iframes:
    src = iframe['src']
    img_tag = soup.new_tag('img', src=src)
    img_tag['width'] = 600
    img_tag['height'] = 400
    iframe.replace_with(img_tag)

  # find all the spans
  spans = soup.find_all('span')
  # for each span, if it has text in the span, then add two <br/> tags after the text, inside the span
  for span in spans:
    if span.text:
      br_tag = soup.new_tag('br')
      br_tag2 = soup.new_tag('br')
      span.append(br_tag)
      span.append(br_tag2)

  # Return the modified HTML
  return soup.prettify()

and deployed it with flask/gunicorn, again on Render.

I added this step as another module in Make.com. Again, this module was a generic HTTP module, making an API call against the flask app I had deployed on Render.

Setting of third module
Setting of third module

Again, note that the output to the second module, which a string of HTML, was the input to the third module.

Output of second module (input of third)
Output of second module (input of third)
  1. Send HTML to create draft in Ghost automatically

Finally, I wanted to take the cleaned up HTML and with it, create a draft of a post in Ghost.

Make.com again made this fairly easy. I could add a Ghost module, add my connection information, and set the HTML to the output of the third module. I decided to create drafts rather than posts in Ghost, because I wanted to run this scenario on a weekly basis, and didn’t want the audience to receive all the tweet threads at same time.

image
image

I scheduled this scenario to run every Sunday at 5 pm. This means that all the tweets for the next week have to be curated by that time (still a lot easier than all the manual copying and pasting I used to do).

image
  1. Create separate Make.com scenario to publish one draft post every Monday, Wednesday, Friday

To actually send the drafts to all my email subscribers three times a week, I created a new scenario that would “promote” one draft at a time to “published” status on that cadence.

get one draft at a time
get one draft at a time
promote draft to publish
promote draft to publish