Skip to content
Go back

从 Notion 到 Astro:构建自动化的博客发布流程

| 阅读时间 10 分钟

从 Notion 到 Astro:构建自动化的博客发布流程

最近重启了个人博客,选择了 Astro 框架配合 AstroPaper 主题。作为一个长期使用 Notion 进行写作和内容管理的用户,我希望能够继续在 Notion 中写作,然后直接发布到博客上。

然而我发现,Notion 导出的 Markdown 文件格式与 Astro 的要求存在明显差异,导致文件无法直接使用。

经过一番折腾,我写了一个 Python 脚本来解决这个问题,现在整个发布流程已经相当顺畅。就以此作为博客的第一篇文章吧。

Table of contents

Open Table of contents

问题在哪里?

首先就是格式问题,Notion 导出的 Markdown 文件,它会自动把你在 Notion 中设置的文章属性,填写到文章的顶部,但是这个格式并不能设置成 Astro 支持的 frontmatter 格式。

Frontmatter 格式问题

Astro 需要标准的 YAML frontmatter 来定义文章元数据:

---
title: 文章标题
pubDatetime: 2025-11-16
tags:
  - Notion
  - React
slug: my-article
---
正文内容...

而 Notion 导出的格式则是这样:

# 文章标题

slug: my-article
pubDatetime: 2025年11月15日
tags: Notion, React

正文内容...

你可以看到,Notion 是自动把文章标题设置成第一个一级标题,然后紧接着把文章属性放在后面,之后才是正文内容。

虽然格式没办法完全匹配,但好在该有的属性都会被自动导出来,所以就需要我们自己将这些属性,手动转换成 frontmatter 格式。

图片路径问题

图片问题是一个相当让人头疼的问题。

首先,你在 Notion 中插入的所有图片,导出之后,它都会将图片和你的 Markdown 文件一起打包下载到本地,然后直接通过文件名引用,像这样:

![](image.jpg)

然而 AstroPaper 要求将本地图片单独放在 src/assets/images/ 目录,并使用路径别名进行引用:

![](@/assets/images/image.jpg)

这就意味这什么?

这就意味着,你必须要手动把这些本地图片,全部移动到src/assets/images/ 文件夹中,然后再去Markdown 文件中,手动替换所有的路径引用。

哇,想想头都大了,要是文章中有100张图片呢?

这个时候你可能会想,我不导出 Markdown 文件,直接复制 Notion 里面的文章内容,粘贴到本地 Markdown 文件中不就行了吗?由于没有导出文件,图片链接应该是 Notion 自己的 url,而不是本地图片,就不用手动移动了。

想法是好想法,但是只能说理想很丰满,现实很骨感。。。

Notion 的图片链接都是有签名的,有效期估计就个把小时,超过时间就会过期无法显示了,除非你自己用图床。

文件结构问题

Notion 导出的是 zip 压缩包,解压后还有一层 zip。

也就是说,每次发布文章你都要:

  1. 解压zip压缩包,得到一个zip压缩包
  2. 再解压zip压缩包,得到 Markdown 文件和一堆本地图片
  3. 把 Markdown 文件移动到目标位置
  4. 把图片移动到另外一个目标位置
  5. 打开 Markdown文件,将所有的图片引用位置,全部更新
  6. 修改头部属性,构建符合标准的 frontmatter

这真的是一件及其繁琐的事情,我相信很快你就会不厌其烦,失去写作的热情了。。。

还有 Open Graph 图片

为了在社交媒体上分享时有好的预览效果,每篇文章最好有个 ogImage。添加这个字段不难,想偷懒的话,我们可以把文章的第一张图片设置成ogImage,但每次都要手动操作也很烦。

解决思路

这些操作都是机械且重复的,完全可以用脚本来实现自动化。

我们希望的功能是,脚本拿到 Notion 导出的原始 zip 文件之后,直接就能输出 最终的符合 astro 标准的 Markdown文件,并且文章和图片都在正确的位置。

OK,既然目标明确了,以下是实现步骤。

深度解压

首先我们可以在项目的根目录新建一个 temp 文件夹,用来存放这个临时的嵌套zip文件。

我们可以递归查找并解压所有 zip 文件,直到没有压缩包为止:

def unzip_all_in_temp():
    while True:
        zip_files = list(TEMP_DIR.glob('*.zip'))
        if not zip_files:
            break
        for zip_path in zip_files:
            with zipfile.ZipFile(zip_path, 'r') as zf:
                zf.extractall(TEMP_DIR)
            zip_path.unlink()

这个循环会一直运行,直到 temp/ 目录里没有 zip 文件。

解析 Notion 格式

我观察了几个 Notion 导出的文件后,发现它们的格式很规律:

标题在第一行,然后是空行,接着是属性列表,再一个空行后是正文。按照这个规律写解析逻辑就可以了:

def parse_and_separate(content: str) -> tuple[dict, str]:
    lines = content.splitlines()
    data = {}

    # 提取标题
    if lines[0].startswith('# '):
        data['title'] = lines[0].lstrip('# ').strip()

    # 跳过空行,开始读取属性
    current_index = 1
    if current_index < len(lines) and lines[current_index].strip() == '':
        current_index += 1

    # 解析 "key: value" 格式的属性
    for i in range(current_index, len(lines)):
        line = lines[i]
        if line.strip() == '':
            body_start_index = i + 1
            break
        if ':' in line:
            key, value = line.split(':', 1)
            data[key.strip()] = value.strip()

    # 剩下的就是正文
    body_text = '\n'.join(lines[body_start_index:])
    return data, body_text

拿到结构化数据后,就可以构建标准的 frontmatter 了。这里需要做一些格式转换,比如把中文日期转成 YYYY-MM-DD 格式,把逗号分隔的标签转成 YAML 列表。

处理图片

图片处理分两种情况。对于本地图片,用正则找到它们,在临时目录里定位文件,移动到 src/assets/images/,然后更新引用路径。

对于外链图片(比如 Unsplash 的图),下载下来,用 URL 的 MD5 作为文件名保存到本地,同样更新引用。这样做有两个好处:一是避免文件名冲突,二是相同的图片只下载一次。

当然,外链图片你也可以选择不下载,但是谁能保证外链的稳定性呢?所以既然我们都已经在写这个脚本了,不然干脆就把这个问题一并解决了。

def process_external_images(content: str, cache: dict) -> str:
    def replacer(match):
        alt_text, url = match.group(1), match.group(2)

        if url in cache:
            return f'![{alt_text}]({cache[url]})'

        response = requests.get(url, timeout=10)
        ext = mimetypes.guess_extension(response.headers.get('content-type')) or '.jpg'
        url_hash = hashlib.md5(url.encode()).hexdigest()
        new_filename = f'{url_hash}{ext}'

        save_path = DEST_IMG_DIR / new_filename
        save_path.write_bytes(response.content)

        new_path = ASTRO_IMG_ALIAS_PATH + new_filename
        cache[url] = new_path

        return f'![{alt_text}]({new_path})'

    return EXTERNAL_IMAGE_PATTERN.sub(replacer, content)
def process_external_images(content: str, cache: dict) -> str:
    '''处理 .md 文件中的所有外链图片'''

    def replacer(match):
        alt_text, url = match.group(1), match.group(2)
        if url in cache:
            return f'![{alt_text}]({cache[url]})'
        try:
            response = requests.get(url, timeout=10)
            response.raise_for_status()
            ext = mimetypes.guess_extension(response.headers.get('content-type')) or '.jpg'
            url_hash = hashlib.md5(url.encode()).hexdigest()
            new_filename = f'{url_hash}{ext}'
            save_path = DEST_IMG_DIR / new_filename
            if not save_path.exists():
                save_path.write_bytes(response.content)
                print(f'  - [下载] {url[:40]}... -> {new_filename}')
            new_path = ASTRO_IMG_ALIAS_PATH + new_filename
            cache[url] = new_path
            return f'![{alt_text}]({new_path})'
        except requests.exceptions.RequestException as e:
            print(f'  - [错误] 下载失败: {url} | {e}', file=sys.stderr)
            return match.group(0)
    return EXTERNAL_IMAGE_PATTERN.sub(replacer, content)

这里我用了一个字典做缓存,对于已经处理过的图片直接返回结果,不用重复下载或移动。

自动设置封面图(可选)

图片都处理完之后,再扫描一遍正文,找到第一张图片,把它设置为 ogImage。这样每篇文章都会自动有封面图,不需要手动配置。

需要注意的是,不同的主题这里需要的字段可能不一样,具体要看你的主题是什么要求了。

def find_first_astro_image(body_text: str) -> str:
    match = ASTRO_OGIMAGE_PATTERN.search(body_text)
    if match:
        filename = match.group(1)
        return f'../../assets/images/{filename}'
    return ''

因为图片路径已经被转换成了 @/assets/images/xxx.jpg 格式,所以直接用正则匹配就能找到。提取出文件名后,构建成相对路径填到 frontmatter 里。

完整流程

把这些模块串起来,整个脚本的处理流程是这样的:

  1. 扫描 temp/ 目录,递归解压所有 zip 文件
  2. 遍历所有 Markdown 文件,逐个处理
  3. 解析文件结构,分离元数据和正文
  4. 处理正文中的本地图片:移动文件 + 路径转换
  5. 处理正文中的外链图片:下载文件 + 路径转换
  6. 扫描处理后的正文,找到第一张图片作为 OG 图
  7. 构建标准 frontmatter,合并正文
  8. 用文章标题作为文件名,保存到 src/data/blog/
  9. 清理临时文件

整个过程是全自动的,不需要任何人工干预,本来可能要3分钟才能操作完的流程,现在1秒钟就搞定了。

现在的工作流

有了这个脚本,我的发布流程变成了这样:

  1. 在 Notion 里写完文章
  2. 选择导出为 Markdown & CSV 格式
  3. 将导出的zip文件保存到 temp/ 目录
  4. 运行 python notion_md_process.py
  5. git add . && git commit -m "new post" && git push

从导出到发布,整个过程不到一分钟。所有格式转换、文件处理都在后台完成,我只需要关心写作本身。

这下,我的博客应该没有断更的理由了 😂😂😂

一些思考

回头看这个问题,技术实现并不复杂。核心是识别出哪些操作是重复的、机械的,然后用代码把它们自动化。

写这个脚本的过程中,我发现很多时候我们会习惯性地接受一些低效的工作流程。比如每次手动解压、复制、粘贴,虽然每次只花几分钟,但累积起来既浪费时间,又容易出错。更重要的是,这些琐碎的操作会打断思路,让人不愿意频繁更新内容。

自动化带来的价值不仅是节省时间,更多的是降低了发布的心理门槛。现在我想发布一篇文章,只需要在 Notion 里写好,然后跑一个脚本就行了。这种流畅的体验会让你更愿意去写,去分享。

如果你也在用 Notion 写作,也在维护一个博客,希望这篇文章能给你一些启发。工具是为人服务的,当工具不够用时,我们完全可以自己动手改进它。

当然,Notion + Astro 还有另外一套更高阶的方案,就是利用 Notion API,直接将文章渲染到 Astro,但是涉及到的东西比较多,日后有时间再研究吧。

完整代码

以下是完整代码,你简单修改一下应该就可以直接使用了

需要先安装 requests 库:

pip install requests

然后创建 notion_md_process.py

import sys
import re
import shutil
import hashlib
import mimetypes
import requests
import zipfile
from pathlib import Path

# --- 配置 ---
TEMP_DIR = Path('temp').resolve()
DEST_MD_DIR = Path('src/data/blog').resolve()
DEST_IMG_DIR = Path('src/assets/images').resolve()
ASTRO_IMG_ALIAS_PATH = '@/assets/images/'

LOCAL_IMAGE_PATTERN = re.compile(
    r'!\[(.*?)\]\(([^)/]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp))\)',
    re.IGNORECASE
)
EXTERNAL_IMAGE_PATTERN = re.compile(
    r'!\[(.*?)\]\((https?://[^\s)]+)\)',
    re.IGNORECASE
)

ASTRO_OGIMAGE_PATTERN = re.compile(r'!\[.*?\]\(@/assets/images/(.*?)\)')
# --- 结束配置 ---

def unzip_all_in_temp():
    '''递归解压 temp 目录中的所有 ZIP 文件'''
    while True:
        zip_files_found = list(TEMP_DIR.glob('*.zip'))
        if not zip_files_found:
            break
        for zip_path in zip_files_found:
            try:
                with zipfile.ZipFile(zip_path, 'r') as zf:
                    zf.extractall(TEMP_DIR)
                zip_path.unlink()
            except zipfile.BadZipFile:
                print(f'  [错误] {zip_path.name} 不是有效的 ZIP 文件。', file=sys.stderr)
            except Exception as e:
                print(f'  [错误] 解压失败 {zip_path.name}: {e}', file=sys.stderr)

def parse_notion_date(date_str: str) -> str:
    '''将 Notion 导出的日期转换为 YYYY-MM-DD 格式'''
    if not date_str:
        return ''
    try:
        date_str = date_str.replace('', '-').replace('', '-').replace('', '')
        return date_str.split()[0]
    except Exception:
        return ''

def parse_and_separate(content: str) -> tuple[dict, str]:
    '''
    解析规则:标题 -> (空行) -> 属性列表 -> (空行) -> 正文
    '''
    data = {}
    lines = content.splitlines()
    body_start_index = 0

    # 1. 查找标题
    if lines and lines[0].startswith('# '):
        data['title'] = lines[0].lstrip('# ').strip()
    else:
        print(f'    - [警告] 未找到以 # 开头的标题。')
        data['title'] = 'untitled'

    # 2. 查找属性
    current_index = 1

    # 跳过第一个空行(如果存在)
    if current_index < len(lines) and lines[current_index].strip() == '':
        current_index += 1

    # 开始解析属性
    for i in range(current_index, len(lines)):
        line = lines[i]

        if line.strip() == '':
            body_start_index = i + 1  # 正文从下一行开始
            break

        # 解析 "key: value"
        if ':' in line:
            try:
                key, value = line.split(':', 1)
                data[key.strip()] = value.strip()
            except ValueError:
                pass

        # 如果没有空行到达末尾,说明没有正文
        if i == len(lines) - 1:
            body_start_index = len(lines)

    # 3. 提取正文
    body_lines = lines[body_start_index:]
    body_text = '\n'.join(body_lines)

    return data, body_text

def find_first_astro_image(body_text: str) -> str:
    '''在处理后的正文中查找第一张图片,返回相对路径'''
    match = ASTRO_OGIMAGE_PATTERN.search(body_text)
    if match:
        filename = match.group(1)
        return f'../../assets/images/{filename}'
    return ''

def build_frontmatter(data: dict) -> str:
    '''根据解析的数据构建标准的 frontmatter 字符串'''
    pub_datetime = parse_notion_date(data.get('pubDatetime', ''))
    mod_datetime = parse_notion_date(data.get('modDatetime', ''))
    tags_str = data.get('tags', '')

    tags_list = [tag.strip() for tag in tags_str.split(',') if tag.strip()]

    tags_yaml = []
    if tags_list:
        tags_yaml = '\n' + '\n'.join(f'  - {tag}' for tag in tags_list)

    # 获取 ogImage 路径
    og_image_path = data.get('ogImage', '') or ''

    return f'''---
title: {data.get('title', 'Untitled')}
author: Harry Leung
pubDatetime: {pub_datetime}
modDatetime: {mod_datetime}
slug: {data.get('slug', '')}
featured: false
draft: false
tags: {tags_yaml}
ogImage: {og_image_path}
description: {data.get('description', '')}
---'''

def process_local_images(content: str, cache: dict) -> str:
    '''处理 .md 文件中的所有本地图片'''

    def replacer(match):
        alt_text, filename = match.group(1), match.group(2)
        if filename in cache:
            return f'![{alt_text}]({cache[filename]})'
        try:
            source_path = next(TEMP_DIR.rglob(filename), None)
            if not source_path:
                print(f'  - [错误] 未找到本地图片: {filename}', file=sys.stderr)
                return match.group(0)
            dest_path = DEST_IMG_DIR / filename
            if not dest_path.exists():
                shutil.move(str(source_path), str(dest_path))
                print(f'  - [移动] {filename}')
            else:
                source_path.unlink()
            new_path = ASTRO_IMG_ALIAS_PATH + filename
            cache[filename] = new_path
            return f'![{alt_text}]({new_path})'
        except Exception as e:
            print(f'  - [错误] 移动本地图片失败: {filename} | {e}', file=sys.stderr)
            return match.group(0)
    return LOCAL_IMAGE_PATTERN.sub(replacer, content)

def process_external_images(content: str, cache: dict) -> str:
    '''处理 .md 文件中的所有外链图片'''

    def replacer(match):
        alt_text, url = match.group(1), match.group(2)
        if url in cache:
            return f'![{alt_text}]({cache[url]})'
        try:
            response = requests.get(url, timeout=10)
            response.raise_for_status()
            ext = mimetypes.guess_extension(response.headers.get('content-type')) or '.jpg'
            url_hash = hashlib.md5(url.encode()).hexdigest()
            new_filename = f'{url_hash}{ext}'
            save_path = DEST_IMG_DIR / new_filename
            if not save_path.exists():
                save_path.write_bytes(response.content)
                print(f'  - [下载] {url[:40]}... -> {new_filename}')
            new_path = ASTRO_IMG_ALIAS_PATH + new_filename
            cache[url] = new_path
            return f'![{alt_text}]({new_path})'
        except requests.exceptions.RequestException as e:
            print(f'  - [错误] 下载失败: {url} | {e}', file=sys.stderr)
            return match.group(0)
    return EXTERNAL_IMAGE_PATTERN.sub(replacer, content)

def main():
    if not TEMP_DIR.exists():
        print(f'[错误] temp 目录 {TEMP_DIR} 不存在。', file=sys.stderr)
        sys.exit(1)

    print(f'[解压] ZIP 文件...')
    unzip_all_in_temp()

    DEST_MD_DIR.mkdir(parents=True, exist_ok=True)
    DEST_IMG_DIR.mkdir(parents=True, exist_ok=True)
    processed_images_cache = {}
    md_files = [fp for fp in TEMP_DIR.rglob('*.md')]

    if not md_files:
        print('temp/ 目录中未找到 Markdown 文件')
        return

    for md_file_path in md_files:
        print(f'[处理] {md_file_path.name}')
        try:
            content = md_file_path.read_text(encoding='utf-8')

            # 1. 解析并分离
            parsed_data, body_text = parse_and_separate(content)

            # 2. 处理正文中的图片(本地)
            body_text = process_local_images(body_text, processed_images_cache)

            # 3. 处理正文中的图片(外链)
            body_text = process_external_images(body_text, processed_images_cache)

            # 4. 处理后查找第一张图片
            og_image_relative_path = find_first_astro_image(body_text)
            parsed_data['ogImage'] = og_image_relative_path

            # 5. 构建 frontmatter
            frontmatter = build_frontmatter(parsed_data)

            # 6. 替换第一张图片的 alt 为文章标题
            title = parsed_data.get('title', 'untitled').strip()
            first_image_pattern = re.compile(
                r'(!\[)[^\]]*(\]\(@/assets/images/[^)]+\))', re.IGNORECASE)
            body_text = first_image_pattern.sub(rf'\1{title}\2', body_text, count=1)

            # 7. 合并并写入最终文件
            safe_title = re.sub(r'[<>:"/\\|?*]', '_', title)
            dest_md_path = DEST_MD_DIR / (safe_title + '.md')
            print(f'[写入] {dest_md_path.name}')
            final_content = frontmatter + '\n\n' + body_text.strip()
            dest_md_path.write_text(final_content, encoding='utf-8')

            # 8. 删除原 md 文件
            md_file_path.unlink()

        except Exception as e:
            print(f'[错误] 处理失败 {md_file_path.name}: {e}', file=sys.stderr)

    # 清理 temp 目录
    print(f'[清理] temp 目录 {TEMP_DIR}')
    for item in TEMP_DIR.iterdir():
        try:
            if item.is_file():
                item.unlink()
        except Exception as e:
            print(f'[错误] 清理失败 {item}: {e}', file=sys.stderr)

    print('\n处理完成。')

if __name__ == '__main__':
    main()

希望这篇文章对你有帮助,欢迎交流!


分享这篇文章:

Previous Post
Cloudflare打个喷嚏,全球互联网都感冒了,互联网基础设施的脆弱和反思