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.