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.