I’ve recently overhauled my website, and to visitors it seems to be just a visual change. But behind the scenes I’ve built a new pipeline to try to eliminate my blog post procrastination. I’m happy with how it turned out, and I’d like to share it with all of you and walk through how it works.
The Problem
When drafting blog posts, taking notes, tracking job applications, and making grocery lists, I like to use Notion. It’s easy to format headers, insert pictures, and it keeps everything synced across my devices. So to create a blog post out of a Notion page, I just have to export the page as HTML, right? While I could do this, I found the exported HTML to be a bit messy. It was full of stuff I didn’t want or need, like internal CSS and various scripts. Exporting the page to Markdown, on the other hand, gave me a clean output to manipulate exactly how I wanted.
For example, here is an excerpt of some exported HTML and the corresponding exported Markdown:
Within the code that handles secret uploads, we can find a vulnerable SQL query.</p><script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js" integrity="sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==" crossorigin="anonymous" referrerPolicy="no-referrer"></script><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" integrity="sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==" crossorigin="anonymous" referrerPolicy="no-referrer"/><script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js" integrity="sha512-m3JYEI6gx5fh9jF10FjGoMzVKcV2N6nchcDcqPCdI1L3R2WQV7br2XVNR8iTLb2daOMRl3zldbcfT40xU2ntVw==" crossorigin="anonymous" referrerPolicy="no-referrer"></script><pre id="3270fee2-3518-8003-a7fc-e487ba15bf48" class="code code-wrap" data-notion-code-syntax="jsx"><code class="language-jsx" style="white-space:pre-wrap;word-break:break-all">app.post('/secrets/create', authMiddleware, async (req, res) => {
const userId = req.userId;
if (!userId){
// if user didn't login, redirect to index page
res.clearCookie('auth_token');
return res.redirect('/');
}
const content = req.body.content;
const query = await db.raw(
`INSERT INTO secrets(owner_id, content) VALUES ('${userId}', '${content}')`
);
return res.redirect('/');
});</code></pre>
Within the code that handles secret uploads, we can find a vulnerable SQL query.
```jsx
app.post('/secrets/create', authMiddleware, async (req, res) => {
const userId = req.userId;
if (!userId){
// if user didn't login, redirect to index page
res.clearCookie('auth_token');
return res.redirect('/');
}
const content = req.body.content;
const query = await db.raw(
`INSERT INTO secrets(owner_id, content) VALUES ('${userId}', '${content}')`
);
return res.redirect('/');
});
```
Even if I had clean HTML code, I would still have to edit it to include my blog post page header, footer, and table of contents. All of these issues led to me procrastinating posting, so I wanted to build a solution.
The Solution
My solution was to create a pipeline that automates as many steps of the Notion to HTML blog post conversion as possible. In practice, this pipeline is just three steps.
Download
The first step is to download the Notion page as Markdown. In the Notion page, I go to the export window and select “Markdown & CSV” as the export format. This will download a zip of the page converted to Markdown along with PNGs of any images on the page. Here is an example of an unzipped export folder:
Transfer
Now that I have the unzipped export folder on my local computer, I need to transfer it to my web server. This can be done with the following command in Command Prompt or PowerShell (assuming I’m one level up from the unzipped export folder named “2026-08-24”):
scp -r .\2026-08-24 grant@grantmullins.net:/var/www/grantmullins.net/blog/source/
The
-r
flag means it will recursively transfer all files in the “2026-08-24” folder and create that same folder in the remote “source” folder.
Convert
With the Markdown and PNG files on my web server, all that is left to do is convert those files into HTML and WebPs, edit the HTML file to fit my blog post template, and update the home page and blog page of my website to show the new blog post. Fortunately, my
build.py
script does all of this, and all I need to do is run the following command from my local computer:
ssh grant@grantmullins.net "cd /var/www/grantmullins.net/blog/source && python3 build.py -iy"
The
-y
flag skips confirmation messages, and the
-i
flag updates the home and blog pages of my website with the new blog post.
That’s the whole process! I hope this automation will encourage me to make more blog posts in the future. For more details on the
build.py
script, see the appendix below.
Appendix: The build.py Script
Here’s a brief overview of my
build.py
script.
Read Arguments
First, I use argparse to read the CLI arguments of the script. All positional arguments are names of directories to convert. In the example above, I could have run
python3 build.py -iy 2026-08-24
and gotten the same effect. I can pass multiple positional arguments as well, for example
python3 build.py -iy 2026-08-24 2025-11-24
, which will convert both of the given directories. Finally, I can run the script with no positional arguments, which will run as if I passed all directories in the current directory as arguments.
There are also some optional arguments (flags) as well. As explained above, the
-y
flag skips a confirmation message that displays all directories to be converted, and the
-i
flag updates the home and blog pages of my website with the new blog post. There’s also a third flag,
-b
, which will convert all given directories regardless of whether a corresponding converted directory already exists. This flag is useful when I’ve made changes to an existing blog post and don’t want to delete the already converted directory. Without the
-b
flag, the script will ignore all given directories that have already been converted, and thus only convert new directories.
Find and Parse Files
After parsing CLI arguments, the script finds all relevant files for each directory to be converted. This includes exactly one Markdown file and optional PNG files. Then, the script parses the Markdown file. The title of the blog post is read from the first header of the file, and the remainder of the Markdown file is considered the body of the blog post. Additionally, all headers in the file are placed into a list to be converted into the blog post’s table of contents.
Create HTML File
After getting the blog post title, body, table of contents, and date (from the directory name), the initial HTML file of the blog post is created. This is done by converting the Markdown body into HTML using the Markdown library, then injecting this along with the title and date into a template file. Finally, the list of Markdown headers is used to create a list of HTML elements with the proper numbering using the following algorithm:
print("[*] Constructing html table of contents")
html_headers = []
html_contents = []
n = [0, 0, 0]
for header in md_contents:
h_pre = re.search(r'#+', header).group().strip()
h_name = re.search(r'[^#]+', header).group().strip()
html_headers.append(h_name)
if h_pre == "####":
n[2] += 1
html_contents.append(f"{n[0]}.{n[1]}.{n[2]}. {h_name}")
elif h_pre == "###":
n[1] += 1
n[2] = 0
html_contents.append(f"{n[0]}.{n[1]}. {h_name}")
elif h_pre == "##":
n[0] += 1
n[1] = 0
n[2] = 0
html_contents.append(f"{n[0]}. {h_name}")
elif h_pre == "#":
html_contents.append(h_name)
html_ids = list(map(generate_html_id, html_contents))
html_ids[0] = "" # Leave h1 id blank so TOC jumps to top of page
html_contents = [f"<a href=\"#{html_id}\">{header}</a>" for html_id, header in zip(html_ids, html_contents)]
These are also injected into the template file, and the blog post is nearly complete.
Convert and Update PNGs
If the blog post contains screenshots, Notion will usually export them as PNGs. However, I want WebPs in the final blog post. Thus, the final step in the conversion process is to convert all PNG files to WebP, then update the blog post HTML to reference the WebP version of each file instead of the PNG version. The HTML is then prettified using BeautifulSoup and written to disk.
Reindex Home and Blog Pages
With the new blog post created, I need to update my home and blog pages to display it. If the
-i
flag is passed, my script will find all posts in the generated directory, rewrite the blog page with all of these, and rewrite the home page with the most recent ones.