Python腳本實現(xiàn)批量發(fā)布Markdown文章
背景
事情是這樣的,我平時有在本地用Markdown寫技術(shù)筆記的習慣,幾年下來積累了上百篇。最近想把它們整理一下,發(fā)布到我的個人博客上,一方面做個備份,另一方面也方便分享。我的博客后臺支持Markdown格式,但手動操作實在要命:每篇都要點“新建文章”,復制標題、內(nèi)容,設(shè)置分類標簽,上傳封面圖……發(fā)個三五篇還行,幾十上百篇簡直是不可能完成的任務。
作為一個懶人程序員,我的第一反應就是:寫個腳本自動化搞定。理想很豐滿,覺得無非就是讀取文件、解析內(nèi)容、調(diào)用API發(fā)送。但真正動手之后才發(fā)現(xiàn),從本地Markdown到線上可發(fā)布的文章,中間隔著好幾個坑。這篇文章就記錄了我從“覺得簡單”到“終于跑通”的全過程。
問題分析
我最開始的思路非常簡單粗暴:
- 遍歷指定文件夾的所有
.md文件。 - 用
open().read()讀取文件內(nèi)容。 - 把內(nèi)容和標題通過博客平臺的API發(fā)出去。
但第一版腳本跑起來就失敗了。首先,我的筆記里有很多圖片,鏈接都是本地的相對路徑,比如 。直接把這個字符串發(fā)到線上,圖片肯定顯示不了。其次,我的博客API要求分類和標簽是ID,但我筆記里習慣用“#Python”、“#踩坑記錄”這樣的文字標簽。最后,博客文章需要一個摘要,我原本打算截取正文前200字,但有些文章開頭是代碼塊,直接截取會破壞格式。
看來,簡單的“讀取-發(fā)送”行不通,必須在中間加一個處理層,把本地的、非結(jié)構(gòu)化的Markdown,轉(zhuǎn)換成符合API要求的結(jié)構(gòu)化數(shù)據(jù)。這個處理層至少要解決三個問題:元信息提取、圖片處理和內(nèi)容格式化。
核心實現(xiàn)
第一步:解析Markdown,提取元信息和正文
我需要從Markdown文件中分離出標題、日期、標簽這些元信息,以及純粹的正文內(nèi)容。我觀察到自己的筆記有個習慣:通常在最前面用YAML Front Matter(就是被---包裹的部分)來記錄這些信息。如果沒有,標題就是第一個一級標題。
我決定使用 python-frontmatter 這個庫來解析Front Matter,用 markdown 庫來幫助處理一些Markdown語法。這里有個坑:python-frontmatter 安裝時名字是 python-frontmatter,但導入時是 import frontmatter。
import os
import frontmatter
from markdown import Markdown
from io import StringIO
def parse_markdown_file(file_path):
"""
解析Markdown文件,提取元數(shù)據(jù)和純正文。
Args:
file_path: Markdown文件的路徑
Returns:
dict: 包含標題、內(nèi)容、標簽等信息的字典
"""
with open(file_path, 'r', encoding='utf-8') as f:
# 使用frontmatter庫解析,它能自動處理有無Front Matter的情況
post = frontmatter.load(f)
# 提取元數(shù)據(jù)
# 標題優(yōu)先從Front Matter的‘title'字段獲取,否則使用第一個一級標題
title = post.get('title', '')
# 標簽從Front Matter的‘tags'字段獲取,默認為空列表
tags = post.get('tags', [])
# 其他可能存在的元數(shù)據(jù),如日期、分類
date = post.get('date', None)
category = post.get('category', '')
# 獲取純文本內(nèi)容(frontmatter.content會自動去掉Front Matter部分)
content = post.content
# 如果frontmatter里沒有標題,嘗試從內(nèi)容中找第一個#標題
if not title:
lines = content.split('\n')
for line in lines:
if line.startswith('# '):
title = line.lstrip('# ').strip()
break
# 如果還找不到,就用文件名(不含后綴)
if not title:
title = os.path.splitext(os.path.basename(file_path))[0]
return {
'title': title,
'content': content,
'tags': tags,
'date': date,
'category': category,
'source_path': file_path
}
第二步:處理本地圖片,上傳并替換鏈接
這是最棘手的一步。我的腳本需要能識別出內(nèi)容里的本地圖片標記,上傳到博客的圖床(或媒體庫),然后把Markdown中的圖片鏈接替換成線上URL。
我寫了一個函數(shù)來查找所有本地圖片鏈接。這里注意,Markdown圖片語法是 ,url可能是相對路徑、絕對路徑,也可能是已經(jīng)存在的網(wǎng)絡(luò)圖片。我需要過濾出那些指向本地文件的路徑。
import re
import requests
from pathlib import Path
def find_local_images(content, md_file_path):
"""
在Markdown內(nèi)容中查找所有本地圖片的引用。
Args:
content: Markdown文本內(nèi)容
md_file_path: 原Markdown文件的絕對路徑,用于解析相對路徑
Returns:
list: 包含圖片信息的字典列表,格式為 [{'alt':'描述', 'local_path':'本地路徑'}, ...]
"""
# 正則匹配Markdown圖片語法 !\[.*?\]\((.*?)\)
# 重點:匹配括號內(nèi)的鏈接部分
pattern = r'!\[(.*?)\]\((.*?)\)'
matches = re.findall(pattern, content)
local_images = []
md_dir = os.path.dirname(md_file_path)
for alt_text, img_path in matches:
# 跳過已經(jīng)是網(wǎng)絡(luò)URL的圖片(以http/https開頭)
if img_path.startswith(('http://', 'https://')):
continue
# 構(gòu)造本地圖片的絕對路徑
# 如果img_path是絕對路徑,直接使用;否則,視為相對于md文件所在目錄的路徑
if not os.path.isabs(img_path):
abs_path = os.path.join(md_dir, img_path)
else:
abs_path = img_path
# 檢查文件是否存在
if os.path.exists(abs_path):
local_images.append({
'alt': alt_text,
'local_path': abs_path,
'markdown_ref': img_path # 原始Markdown中的引用路徑,用于后續(xù)替換
})
else:
print(f"警告:圖片文件不存在 {abs_path}")
return local_images
找到圖片后,就需要上傳。我的博客平臺提供了文件上傳API,返回一個訪問URL。我模擬了這個過程,核心是使用 requests 庫發(fā)送 multipart/form-data 請求。
def upload_image_to_blog(local_image_path, alt_text=''):
"""
模擬將本地圖片上傳到博客平臺。
在實際使用中,你需要替換成自己博客平臺的真實API。
Args:
local_image_path: 本地圖片文件路徑
alt_text: 圖片描述文本
Returns:
str: 上傳成功后,圖片的網(wǎng)絡(luò)訪問URL
"""
# !??!這里是需要你根據(jù)自己博客API修改的部分 ?。?!
upload_url = "https://api.your-blog.com/v1/upload" # 替換為真實上傳地址
headers = {
"Authorization": "Bearer YOUR_ACCESS_TOKEN" # 替換為你的認證信息
}
with open(local_image_path, 'rb') as f:
files = {'file': (os.path.basename(local_image_path), f, 'image/png')} # 可根據(jù)文件類型調(diào)整mime
try:
# 發(fā)送上傳請求
resp = requests.post(upload_url, headers=headers, files=files, timeout=30)
resp.raise_for_status() # 如果狀態(tài)碼不是200,拋出異常
result = resp.json()
# 假設(shè)API返回的JSON中有一個‘url'字段
image_url = result.get('url')
if not image_url:
print(f"警告:上傳成功但未返回URL,響應內(nèi)容:{result}")
return None
print(f"圖片上傳成功:{local_image_path} -> {image_url}")
return image_url
except requests.exceptions.RequestException as e:
print(f"圖片上傳失敗 {local_image_path}: {e}")
return None
有了上傳函數(shù),就可以遍歷所有本地圖片,上傳并替換原文中的鏈接了。這里要注意,替換時要使用原始的那個相對路徑字符串(markdown_ref)進行精確替換。
def process_and_replace_images(parsed_post):
"""
處理文章中的本地圖片:上傳并替換鏈接。
會直接修改傳入的 parsed_post['content']。
Args:
parsed_post: 從 parse_markdown_file 返回的字典
Returns:
dict: 更新了content字段的parsed_post
"""
content = parsed_post['content']
md_file_path = parsed_post['source_path']
local_images = find_local_images(content, md_file_path)
if not local_images:
print(f"文章 '{parsed_post['title']}' 未發(fā)現(xiàn)本地圖片。")
return parsed_post
print(f"文章 '{parsed_post['title']}' 發(fā)現(xiàn) {len(local_images)} 張本地圖片,開始處理...")
for img_info in local_images:
online_url = upload_image_to_blog(img_info['local_path'], img_info['alt'])
if online_url:
# 關(guān)鍵步驟:將原文中的舊路徑替換為新的網(wǎng)絡(luò)URL
# 使用原始markdown_ref進行精確替換,避免誤替換
old_md_syntax = f'![{img_info[\"alt\"]}]({img_info[\"markdown_ref\"]})'
new_md_syntax = f'![{img_info[\"alt\"]}]({online_url})'
content = content.replace(old_md_syntax, new_md_syntax)
else:
print(f"圖片 {img_info['local_path']} 上傳失敗,鏈接未替換。")
parsed_post['content'] = content
return parsed_post
第三步:調(diào)用發(fā)布API,完成文章創(chuàng)建
處理完內(nèi)容,最后一步就是調(diào)用博客的發(fā)布接口。這里需要將標簽、分類等文本信息,轉(zhuǎn)換為平臺所需的ID格式。我通常會在腳本里維護一個映射字典。另外,為了友好,我添加了簡單的進度提示。
def publish_post_to_blog(post_data):
"""
模擬調(diào)用博客平臺的API來發(fā)布文章。
Args:
post_data: 包含標題、內(nèi)容、標簽等信息的字典
Returns:
bool: 發(fā)布是否成功
"""
# ?。?!這里是需要你根據(jù)自己博客API修改的部分 ?。?!
api_url = "https://api.your-blog.com/v1/posts"
headers = {
"Authorization": "Bearer YOUR_ACCESS_TOKEN",
"Content-Type": "application/json"
}
# 將標簽文字轉(zhuǎn)換為平臺ID(這里簡化處理,實際可能需要查詢API)
tag_name_to_id = {"Python": 1, "踩坑記錄": 2, "自動化": 3} # 示例映射
tag_ids = []
for tag_name in post_data.get('tags', []):
if tag_name in tag_name_to_id:
tag_ids.append(tag_name_to_id[tag_name])
else:
print(f"警告:標簽 '{tag_name}' 未找到對應ID,已忽略。")
# 構(gòu)造API請求體
payload = {
"title": post_data['title'],
"content": post_data['content'],
"status": "publish", # 直接發(fā)布,也可以是'draft'存草稿
"tags": tag_ids,
"category": post_data.get('category', ''),
}
try:
resp = requests.post(api_url, json=payload, headers=headers, timeout=30)
resp.raise_for_status()
print(f"文章發(fā)布成功!標題:{post_data['title']}")
return True
except requests.exceptions.RequestException as e:
print(f"文章發(fā)布失敗 '{post_data['title']}': {e}")
if resp:
print(f"響應內(nèi)容:{resp.text}")
return False
完整代碼
把上面的步驟串聯(lián)起來,就得到了主程序。我增加了命令行參數(shù)支持,可以指定要發(fā)布的文件夾路徑。
#!/usr/bin/env python3
"""
Markdown文章批量發(fā)布腳本
作者:一個踩坑的全棧
功能:遍歷指定目錄下的.md文件,解析內(nèi)容,上傳圖片,并發(fā)布到博客平臺。
使用前請根據(jù)你的博客API修改 upload_image_to_blog 和 publish_post_to_blog 函數(shù)。
"""
import os
import re
import argparse
import frontmatter
import requests
from pathlib import Path
from time import sleep
# ---------- 第一部分:解析Markdown ----------
def parse_markdown_file(file_path):
"""解析Markdown文件,提取元數(shù)據(jù)和純正文。"""
with open(file_path, 'r', encoding='utf-8') as f:
post = frontmatter.load(f)
title = post.get('title', '')
tags = post.get('tags', [])
date = post.get('date', None)
category = post.get('category', '')
content = post.content
if not title:
lines = content.split('\n')
for line in lines:
if line.startswith('# '):
title = line.lstrip('# ').strip()
break
if not title:
title = os.path.splitext(os.path.basename(file_path))[0]
return {
'title': title,
'content': content,
'tags': tags,
'date': date,
'category': category,
'source_path': file_path
}
# ---------- 第二部分:處理圖片 ----------
def find_local_images(content, md_file_path):
"""在Markdown內(nèi)容中查找所有本地圖片的引用。"""
pattern = r'!\[(.*?)\]\((.*?)\)'
matches = re.findall(pattern, content)
local_images = []
md_dir = os.path.dirname(md_file_path)
for alt_text, img_path in matches:
if img_path.startswith(('http://', 'https://')):
continue
if not os.path.isabs(img_path):
abs_path = os.path.join(md_dir, img_path)
else:
abs_path = img_path
if os.path.exists(abs_path):
local_images.append({
'alt': alt_text,
'local_path': abs_path,
'markdown_ref': img_path
})
else:
print(f"警告:圖片文件不存在 {abs_path}")
return local_images
def upload_image_to_blog(local_image_path, alt_text=''):
"""
模擬將本地圖片上傳到博客平臺。
【重要】請根據(jù)你的博客API修改此函數(shù)!
"""
# ?。?! 示例代碼,需要替換 ?。?!
upload_url = "https://api.your-blog.com/v1/upload"
headers = {"Authorization": "Bearer YOUR_ACCESS_TOKEN"}
with open(local_image_path, 'rb') as f:
files = {'file': (os.path.basename(local_image_path), f, 'image/png')}
try:
resp = requests.post(upload_url, headers=headers, files=files, timeout=30)
resp.raise_for_status()
result = resp.json()
image_url = result.get('url')
if not image_url:
print(f"警告:上傳成功但未返回URL")
return None
print(f"圖片上傳成功:{local_image_path}")
return image_url
except Exception as e:
print(f"圖片上傳失敗 {local_image_path}: {e}")
return None
def process_and_replace_images(parsed_post):
"""處理文章中的本地圖片:上傳并替換鏈接。"""
content = parsed_post['content']
md_file_path = parsed_post['source_path']
local_images = find_local_images(content, md_file_path)
if not local_images:
return parsed_post
print(f"處理圖片,共 {len(local_images)} 張...")
for img_info in local_images:
online_url = upload_image_to_blog(img_info['local_path'], img_info['alt'])
if online_url:
old_syntax = f'![{img_info[\"alt\"]}]({img_info[\"markdown_ref\"]})'
new_syntax = f'![{img_info[\"alt\"]}]({online_url})'
content = content.replace(old_syntax, new_syntax)
else:
print(f"圖片上傳失敗,鏈接未替換。")
parsed_post['content'] = content
return parsed_post
# ---------- 第三部分:發(fā)布文章 ----------
def publish_post_to_blog(post_data):
"""
模擬調(diào)用博客平臺的API來發(fā)布文章。
【重要】請根據(jù)你的博客API修改此函數(shù)!
"""
# !?。?示例代碼,需要替換 !?。?
api_url = "https://api.your-blog.com/v1/posts"
headers = {
"Authorization": "Bearer YOUR_ACCESS_TOKEN",
"Content-Type": "application/json"
}
tag_name_to_id = {"Python": 1, "踩坑記錄": 2, "自動化": 3}
tag_ids = []
for tag_name in post_data.get('tags', []):
if tag_name in tag_name_to_id:
tag_ids.append(tag_name_to_id[tag_name])
else:
print(f"警告:標簽 '{tag_name}' 未找到對應ID")
payload = {
"title": post_data['title'],
"content": post_data['content'],
"status": "publish",
"tags": tag_ids,
"category": post_data.get('category', ''),
}
try:
resp = requests.post(api_url, json=payload, headers=headers, timeout=30)
resp.raise_for_status()
print(f"發(fā)布成功!標題:{post_data['title']}")
return True
except Exception as e:
print(f"發(fā)布失敗 '{post_data['title']}': {e}")
return False
# ---------- 主程序 ----------
def main(markdown_dir):
"""遍歷目錄,處理并發(fā)布所有Markdown文件。"""
if not os.path.isdir(markdown_dir):
print(f"錯誤:目錄不存在 {markdown_dir}")
return
md_files = []
for root, dirs, files in os.walk(markdown_dir):
for file in files:
if file.lower().endswith('.md'):
md_files.append(os.path.join(root, file))
if not md_files:
print(f"在目錄 {markdown_dir} 中未找到.md文件。")
return
print(f"找到 {len(md_files)} 篇Markdown文章,開始處理...")
success_count = 0
for i, md_file in enumerate(md_files, 1):
print(f"\n--- 正在處理第 {i}/{len(md_files)} 篇: {os.path.basename(md_file)} ---")
# 1. 解析
parsed = parse_markdown_file(md_file)
print(f"標題:{parsed['title']}")
# 2. 處理圖片
parsed = process_and_replace_images(parsed)
# 3. 發(fā)布
if publish_post_to_blog(parsed):
success_count += 1
# 可選:避免請求過快,添加延遲
sleep(1)
print(f"\n處理完成!成功發(fā)布 {success_count}/{len(md_files)} 篇文章。")
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='批量發(fā)布Markdown文章到博客')
parser.add_argument('dir', help='包含.md文件的目錄路徑')
args = parser.parse_args()
main(args.dir)
踩坑記錄
編碼問題導致亂碼:一開始沒指定 encoding='utf-8',在Windows下打開某些包含中文的Markdown文件時直接報錯 UnicodeDecodeError。解決:所有文件操作都顯式指定 utf-8 編碼。
圖片路徑解析錯誤:我的正則最初只匹配了 (.*),結(jié)果當圖片描述里包含右括號 ) 時,匹配就提前結(jié)束了,比如 。解決:將正則改為惰性匹配 (.*?),確保匹配到第一個右括號就停止。
替換圖片鏈接時誤傷:最早我用 content.replace(img_path, online_url) 來替換,結(jié)果發(fā)現(xiàn)如果兩篇不同文章里的圖片名字相同,或者正文其他地方出現(xiàn)了同樣的路徑字符串,就會被錯誤替換。解決:改為替換完整的Markdown圖片語法  -> ,做到了精確一對一替換。
API限流和超時:在批量上傳幾十張圖片時,連續(xù)快速請求被服務器限流了,返回429錯誤。解決:在每處理完一篇文章或上傳完一張圖片后,用 time.sleep(1) 添加一個短暫的延遲,模擬人工操作,問題就解決了。同時給 requests 調(diào)用加上 timeout 參數(shù),避免網(wǎng)絡(luò)不佳時腳本無限掛起。
小結(jié)
通過這個項目,我最大的收獲是認識到“自動化”不僅僅是調(diào)用API,更重要的是數(shù)據(jù)轉(zhuǎn)換和異常處理。把本地雜亂的數(shù)據(jù)規(guī)整成API能“吃”下去的格式,這個過程占了80%的工作量。腳本跑通后,我一次性發(fā)布了50多篇舊筆記,節(jié)省了至少十幾個小時的手動操作時間。后續(xù)還可以考慮加入失敗重試、發(fā)布前預覽、更復雜的元信息匹配等功能,讓這個工具更加健壯。
以上就是Python腳本實現(xiàn)批量發(fā)布Markdown文章的詳細內(nèi)容,更多關(guān)于Python發(fā)布Markdown文章的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
python統(tǒng)計函數(shù)庫scipy.stats的用法解析
今天小編就為大家分享一篇python統(tǒng)計函數(shù)庫scipy.stats的用法解析,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-02-02
Python基礎(chǔ)之賦值,淺拷貝,深拷貝的區(qū)別
這篇文章主要介紹了Python基礎(chǔ)之賦值,淺拷貝,深拷貝的區(qū)別,文中有非常詳細的代碼示例,對正在學習python基礎(chǔ)的小伙伴們也有非常好的幫助,需要的朋友可以參考下2021-04-04
python suds訪問webservice服務實現(xiàn)
這篇文章主要介紹了python suds訪問webservice服務實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面來一起學習學習吧2020-06-06
一文帶你了解Python中的數(shù)據(jù)序列化與反序列化
Python提供了豐富的工具和庫來處理數(shù)據(jù)序列化與反序列化,本文帶領(lǐng)大家一起學習,包括基本概念、常見的序列化格式、示例和最佳實踐,快跟隨小編一起學習起來吧2023-10-10
使用Python?http.server模塊共享文件的方法詳解
大家好,今天給大家介紹一下Python標準庫中的http.server模塊,這個模塊提供了一種簡單的方式來快速啟動一個HTTP服務器,文中給大家介紹了使用Python?http.server模塊共享文件的方法,需要的朋友可以參考下2024-05-05

