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.
- 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.
- 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:
To start off, I add a Notion module, for watching Notion database items
- 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.
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.
- 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.
Again, note that the output to the second module, which a string of HTML, was the input to the third module.
- 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.
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).
- 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.