Markdown SEO Frontmatter 批量生成器
📖 脚本介绍
此时此刻,Astro 框架 正受到广泛关注,此脚本正是为那些 Obsidian、Typera 及本地 Markdown 文档 用户设计的批量 SEO Frontmatter 生成与文章分类整理工具的增强版本。它能够将本地笔记批量转换为适配 Astro 静态网站 并兼容 Firefly 主题 的标准格式,实现从笔记到博客的无缝迁移。
脚本通过本地 AI 服务(支持 Ollama、LM Studio 等 OpenAI API 兼容服务)实现智能化文章分类和 SEO 属性生成,大幅提升内容整理效率。
✨ 核心功能特性
1. 智能文章分类
- 自动分类模式:使用本地 AI 模型分析文章内容,自动归类到预定义目录
- 严谨分类逻辑:采用专用的低温度配置(0.01)确保分类结果准确一致
- 格式严格校验:增强提示词确保 AI 输出标准分类名称
2. SEO Frontmatter 自动生成
- 完整属性生成:自动生成
title、description、tags、slug、image、published、updated等必要字段。(image作为Astro Firefly 主题的封面变量,可按需配置使用二次元随机API或自定义封面图片地址) - SEO 优化规则:
- 标题:基于爆款标题公式,包含相关 Emoji
- 描述:疑问句式 + 解决方案,80-100字,包含核心关键词
- 标签:三层标签法(赛道/行业 + 内容深度/方法论 + 核心术语/SEO关键词)
- Slug:英文小写单词连接,14个单词以内,无中文字符
3. 灵活的更新模式
- 全局更新模式:重写所有文件的 Frontmatter
- 增量更新模式:仅补全缺失的 Frontmatter 字段
- 字段保留规则:
- 智能保留有效的
published日期 - 强制更新
updated字段为当前日期 - 符合格式要求的
slug字段默认保留(避免 URL 变化)
- 智能保留有效的
4. 目录管理功能
- 自动文件整理:按分类移动文件到对应目录
- 空目录清理:自动清理移动后产生的空目录
- 跨平台兼容:支持 macOS、Windows、Linux 系统文件忽略
5. 高度可配置
- 支持自定义分类列表
- 可调节 AI 温度参数(分类与 SEO 生成使用不同温度)
- 多种日期模式选择
- 灵活的更新策略配置
⚙️ 环境要求
此脚本仅需 Python 标准库,无需额外安装任何第三方包!
基础环境
- Python 3.7+
- 网络连接(用于访问本地 AI 服务)
AI 服务要求
- 本地 AI 服务:Ollama、LM Studio 或其他兼容 OpenAI API 的服务
- 模型建议:支持中文的模型,如
qwen3-vl-30b-a3b-instruct-mlx(思考推理模型效果更好,但速度较慢。请按需配置模型,若笔记不敏感强烈建议还是使用平台的API 是更高效的方式。) - 服务地址:
http://localhost:1234/v1/chat/completions(可自定义)
操作系统
- ✅ macOS
- ✅ Windows
- ✅ Linux
💻 脚本源码
完整复制下方代码,并保存命名为 obsdian-seo-plus-enhanced.py 文件
#!/usr/bin/env python3"""增强版 Obsidian SEO Frontmatter 生成与文章分类整理脚本
功能:1. 扫描指定目录下的所有Markdown文件2. 可选模式:自动分类整理(使用AI分类并移动文件)或默认模式(使用目录名作为分类)3. 为文章生成SEO优化的Frontmatter属性(title, description, tags, slug, image, published等)4. 支持全局更新和增量更新模式5. 自动清理空目录
配置说明:- 根据需求修改下面的配置区域- ORGANIZE_MODE 控制目录整理模式:'auto'(自动分类整理)或 'default'(使用目录名作为分类)- UPDATE_MODE 控制Frontmatter更新模式:'global'(全局重写)或 'incremental'(增量补全)
重要说明:- updated字段强制更新:不管是增量更新还是全局更新,updated字段都会被更新为当前日期- slug字段默认保留:如果slug符合格式要求(只包含英文小写字母、数字和连字符),则保留原值"""
import argparseimport jsonimport osimport randomimport reimport shutilimport urllib.errorimport urllib.requestfrom datetime import datetime, timedelta
# ==================== 配置区域 ====================# 注意:以下配置项需要根据你的实际情况进行修改
# --- AI API 配置 ---API_URL = "http://localhost:1234/v1/chat/completions" # 本地AI服务地址,支持Ollama、LM Studio等兼容OpenAI API的服务MODEL_NAME = "qwen3-vl-30b-a3b-instruct-mlx" # 模型名称,根据你的本地模型修改TEMPERATURE = 0.3 # SEO生成温度配置 (0.0 - 1.0),值越高输出越随机,值越低输出越确定CLASSIFY_TEMPERATURE = 0.01 # 分类专用温度配置,较低的值使分类更确定
# --- 目录路径配置 ---SOURCE_DIR = "./WEBNOTE" # 请确认你的笔记根目录,支持相对路径或绝对路径
# --- 目录整理模式配置 ---# 可选模式:# 1. "auto": 自动分类整理模式 - 使用AI对文章进行分类,并移动到对应分类目录# 2. "default": 默认模式 - 使用文件所在目录名作为分类,不移动文件ORGANIZE_MODE = "auto" # 可切换为 "auto" 或 "default"
# 当 ORGANIZE_MODE 为 "auto" 时,需要配置以下分类列表CATEGORIES = [ "Astro教程", "AI实验室", "NAS私有云", "私有化部署", "虚拟化与运维", "网络与安全", "硬件教程", "增长与SEO", "认知与成长", "光影与生活", "未分类",]
# 系统提示词:指导AI进行分类# 提示词质量直接影响分类准确性# 可以调整提示词以更好地适应你的分类需求SYSTEM_PROMPT = f"""你是一位专业的数字内容管理专家(博客整理专家)。请分析文章内容,从下列分类列表中选出最匹配的一个目录。
**核心原则**:根据文章的主体、目的和最终交付物三个维度最终确定分类列表中最匹配的分类。
**模糊判断**:当有疑问时,问自己:“这篇文章让读者学到什么?”- 想教会读者**做一件事** → 技术/工具类目录。- 想启发读者**思考一件事** → 内容/认知类目录。
**格式要求(严格遵守)**:1. 输出必须完全匹配分类列表中的完整名称,不能添加、删除或修改任何字符2. 输出前后不能有任何空格、标点、引号、括号或其他字符3. 只能输出单个分类名称,不能有"分类:"、"目录:"等前缀4. 如果文章内容无法匹配下列任何分类,必须严格输出"未分类"三个字
**输出示例(正确格式)**:AI实验室光影与生活Astro教程
**错误格式示例(避免)**:分类:AI实验室 (错误:包含前缀)AI实验室。 (错误:包含标点) AI实验室 (错误:包含空格)AI实验室, 虚拟化与运维 (错误:多个分类)
**分类决策流程**:1. 阅读文章标题和内容2. 对照分类列表,思考每个分类的定义3. 选择最匹配的分类(即使不完全匹配,也选择最接近的)4. 如果完全无法匹配,输出"未分类"
分类列表:{", ".join(CATEGORIES)}"""
# --- 日期模式配置 ---# 可选模式:# 1. "fixed": 使用固定的自定义日期(格式如2026-01-10)# 2. "random_year": 使用当前日期近一年的随机任意一天(格式如2025-02-10)# 3. "current": 使用当前日期DATE_MODE = "current" # 可切换为 "fixed" 或 "random_year"FIXED_DATE = "2026-01-10" # 当 DATE_MODE 为 "fixed" 时使用
# --- 更新模式配置 ---UPDATE_MODE = "global" # 可切换为 "global" 或 "incremental"
# --- 清理配置 ---# 忽略的文件模式:这些文件不会被计入目录是否为空的条件# 当清理空目录时,这些文件将被忽略,不会阻止目录被删除IGNORE_PATTERNS = [ # macOS 系统文件(访达自动生成) ".DS_Store", "._.DS_Store", ".localized", ".Spotlight-V100", ".Trashes", ".fseventsd", "._*", # Windows 系统文件(资源管理器自动生成) "Thumbs.db", "ehthumbs.db", "desktop.ini", # Linux 系统文件(某些桌面环境自动生成) ".directory", ".Trash-*", # 通用临时文件 "*~", "*.tmp", "*.temp", "~*", "*.bak", # 版本控制文件 ".gitkeep", ".gitignore",]
# 忽略的目录列表:这些目录及其子目录将被跳过,不进行处理IGNORE_DIRS = [ # Windows 系统目录 "$RECYCLE.BIN", "System Volume Information", # macOS 系统目录 ".TemporaryItems", ".DocumentRevisions-V100", ".fseventsd", ".Spotlight-V100", ".Trashes", # Linux 系统目录 ".lost+found", ".Trash-*",]
# 调试模式:控制清理过程中的详细输出DEBUG_MODE = False
# ==================== 工具函数 ====================
def should_ignore_file(filename): """检查文件是否应该被忽略(不参与空目录判断)""" import fnmatch
# 转换为小写以进行不区分大小写的匹配 filename_lower = filename.lower()
for pattern in IGNORE_PATTERNS: # 将模式转换为小写(保留通配符) pattern_lower = pattern.lower()
# 支持通配符匹配(不区分大小写) if fnmatch.fnmatch(filename_lower, pattern_lower): return True # 向后兼容:如果模式没有通配符,检查开头或结尾 if "*" not in pattern: if filename_lower.startswith(pattern_lower) or filename_lower.endswith( pattern_lower ): return True return False
def should_ignore_directory(dir_path): """ 检查目录是否应该被忽略
参数: dir_path: 目录路径
返回: True如果目录在忽略列表中,否则False """ import fnmatch import os.path
if not IGNORE_DIRS: return False
# 获取目录的绝对路径 abs_dir = os.path.abspath(dir_path) # 规范化路径大小写(Windows不区分大小写) abs_dir_norm = os.path.normcase(abs_dir)
for ignore_pattern in IGNORE_DIRS: # 处理绝对路径模式 if os.path.isabs(ignore_pattern): abs_ignore = ignore_pattern else: # 相对路径是相对于SOURCE_DIR abs_ignore = os.path.abspath(os.path.join(SOURCE_DIR, ignore_pattern))
# 规范化忽略路径大小写 abs_ignore_norm = os.path.normcase(abs_ignore)
# 检查模式是否包含通配符 if "*" in ignore_pattern or "?" in ignore_pattern or "[" in ignore_pattern: # 使用通配符匹配 if fnmatch.fnmatch(abs_dir_norm, abs_ignore_norm): return True # 也检查目录是否以模式开头(对于目录通配符) if abs_ignore_norm.endswith("*"): pattern_base = abs_ignore_norm.rstrip("*") if abs_dir_norm.startswith(pattern_base): return True else: # 无通配符,使用前缀匹配 if abs_dir_norm.startswith(abs_ignore_norm): return True
return False
def get_current_date(): """根据 DATE_MODE 配置获取当前日期""" if DATE_MODE == "fixed": return FIXED_DATE elif DATE_MODE == "random_year": # 生成当前日期近一年内的随机日期 end_date = datetime.now() start_date = end_date - timedelta(days=365)
# 计算两个日期之间的天数差 days_diff = (end_date - start_date).days
# 生成随机天数偏移 random_days = random.randint(0, days_diff)
# 计算随机日期 random_date = start_date + timedelta(days=random_days)
# 格式化为 YYYY-MM-DD return random_date.strftime("%Y-%m-%d") elif DATE_MODE == "current": # 返回当前日期 return datetime.now().strftime("%Y-%m-%d") else: # 默认返回当前日期(兼容旧配置) return datetime.now().strftime("%Y-%m-%d")
def validate_published(published_str): """验证published字段格式是否符合YYYY-MM-DD规范""" if not published_str: return False # 正则匹配YYYY-MM-DD格式 pattern = r"^\d{4}-\d{2}-\d{2}$" return bool(re.match(pattern, published_str))
def clean_slug_text(slug_value): """清理slug值,移除所有非字母数字字符,只保留英文、数字和连字符""" if not slug_value: return slug_value
import re
# 转换为小写 cleaned = slug_value.lower()
# 将所有非字母数字字符(a-z0-9)替换为连字符 cleaned = re.sub(r"[^a-z0-9]+", "-", cleaned)
# 移除重复的连字符 cleaned = re.sub(r"-+", "-", cleaned)
# 移除开头和结尾的连字符 cleaned = cleaned.strip("-")
return cleaned
def extract_and_clean_slug(frontmatter_text, filename=None): """从Frontmatter文本中提取并清理slug值,如果清理后的slug不理想则基于文件名生成后备slug""" if not frontmatter_text: return frontmatter_text
import re
# 查找slug: "value" 或 slug: value 格式 pattern = r'(slug:\s*["\']?)([^"\'\n]+)(["\']?)' match = re.search(pattern, frontmatter_text, re.IGNORECASE)
if not match: return frontmatter_text
full_match = match.group(0) prefix = match.group(1) slug_value = match.group(2) suffix = match.group(3)
# 清理slug值 cleaned_slug = clean_slug_text(slug_value)
# 检查清理后的slug质量 # 如果slug太短(少于3个字符)或者不包含字母,则使用后备方案 if ( not cleaned_slug or len(cleaned_slug) < 3 or not re.search(r"[a-z]", cleaned_slug) ): # 基于文件名生成后备slug if filename: base_name = os.path.splitext(filename)[0] cleaned_slug = clean_slug_text(base_name) # 如果文件名清理后仍然不理想,使用通用后备 if ( not cleaned_slug or len(cleaned_slug) < 3 or not re.search(r"[a-z]", cleaned_slug) ): cleaned_slug = "article-" + clean_slug_text(base_name) else: cleaned_slug = "article-slug"
# 如果清理后的slug与原始相同,返回原始文本 if cleaned_slug == slug_value: return frontmatter_text
new_slug_line = f'slug: "{cleaned_slug}"' cleaned_frontmatter = frontmatter_text.replace(full_match, new_slug_line)
return cleaned_frontmatter
def extract_published_from_frontmatter(fm_text): """从Frontmatter文本中提取published值""" if not fm_text: return None # 匹配 published: YYYY-MM-DD 格式,支持前后空格和不同大小写 pattern = r"published:\s*([^\s\n]+)" match = re.search(pattern, fm_text, re.IGNORECASE) if match: return match.group(1).strip() return None
def extract_slug_from_frontmatter(fm_text): """从Frontmatter文本中提取slug值""" if not fm_text: return None # 匹配 slug: value 格式,支持引号 pattern = r'slug:\s*["\']?([^"\'\n]+)["\']?' match = re.search(pattern, fm_text, re.IGNORECASE) if match: return match.group(1).strip() return None
def validate_slug(slug_value): """验证slug是否符合格式要求:只包含英文小写字母、数字和连字符""" if not slug_value: return False # 正则匹配:只允许小写字母、数字和连字符 # 格式要求:不能为空,不能以连字符开头或结尾,不能有连续连字符 pattern = r"^[a-z0-9]+(?:-[a-z0-9]+)*$" return bool(re.match(pattern, slug_value))
def call_classify_ai(title, content): """ 调用本地AI API获取文章分类(用于自动分类模式)
参数: title: 文章标题 content: 文章内容
返回: 分类名称或None(调用失败时) """ payload = { "model": MODEL_NAME, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"标题:{title}\n内容片段:{content[:800]}"}, ], "temperature": CLASSIFY_TEMPERATURE, } try: req = urllib.request.Request( API_URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=300) as response: res = json.loads(response.read().decode("utf-8")) ans = res["choices"][0]["message"]["content"].strip() # 确保返回的分类在预定义列表中 for c in CATEGORIES: if c in ans: return c # 如果返回的分类不在列表中,返回None return None except Exception as e: print(f"⚠️ 分类AI调用失败: {e}") return None
def get_seo_frontmatter_ai(filename, category, content): """调用 AI 生成符合新规则的 Frontmatter""" clean_filename = filename.replace(".md", "") current_date = get_current_date()
# 构造强化后的 Prompt prompt = f"""请为这篇文章生成 YAML Frontmatter 属性。
【属性生成规则】 1. title: - 基于"爆款标题公式"生成。 - 要求更吸引人、符合 SEO,30字以内。 - 标题禁止包含 Emoji。 2. description: - 基于"用户关注点(疑问句式+解决方案)"生成。 - 长度在 80-100 字左右,单行文本。 - 必须包含文章核心关键词(即下方的 tags)。3. tags: - 请阅读文章内容,并严格按照“三层标签法”生成 3 个准确、简洁且具备高检索权重的标签。 - 第一层:所属赛道/行业(指明文章的宏观归属,如:自媒体运营、职场干货)。 - 第二层:内容深度/方法论(提炼文章的交付属性,如:实战复盘、避坑指南、底层逻辑)。 - 第三层:核心术语/SEO关键词(锁定高频搜索的专有名词,如:SEO优化、私域引流、AI绘画)。 - 分析文章的核心受众和搜索意图,最终提取最符合上述三层定义的标签。 - 每个标签必须确保有且只有一个唯一的标签词。 - 每个标签的长度需控制在 **2-6个汉字** 或 **3个英文单词以内**。 - 标签可以使用纯中文、纯英文或中英混合。 - **命名规则**:字符串内绝对禁止使用空格。所有特殊字符(包括空格)均用连字符“-”替换。 - **输出格式**:必须严格按照 YAML 列表格式输出。 - 每个标签单独一行,并以“- ”开头。 - 禁止:逗号分隔列表(如 "标签1, 标签2")或井号标签(如 #标签 )格式。 4. slug: - 先读取文章标题,然后严格按要求使用14个以内的小写英文单词,来概括优化后的title(标题),词间用"-"连接,不允许空格。 - slug值必须只包含英文小写字母(a-z)、数字(0-9)和连字符(-),绝对禁止使用任何中文字符、特殊字符或其他语言字符。 - 绝对禁止使用中文字符,任何非英文字符都必须被转换为连字符或完全移除。 - slug必须描述文章核心内容,让读者从slug就能理解文章主题。 - 示例:对于中文标题"电影《垫底辣妹》的励志教育启示",slug应为:"bottom-girl-movie-inspirational-education-insights" - 示例:对于标题"0基础注册公众号避坑指南",slug应为:"wechat-official-account-registration-beginner-guide-avoid-pitfalls" 5. image: - 固定值为"api"。 6. published: - 格式为 YYYY-MM-DD(例如:2025-01-14)。
【格式示例】title: "独立开发者英文 SEO 站 0-1 变现全流程 SOP"description: "如何通过SEO小站实现被动收入?本文详细拆解独立开发者Leo在9个月内从0赚到4万美金的实战SOP,提供从关键词挖掘到外链建设的全流程方案,助你快速跑通变现闭环。"category: {category}tags: - 自媒体运营 - 实战复盘 - SEO优化slug: "leo-seo-site-0-to-40k-in-9-months"注意:这是正确的slug格式示例,必须严格遵守。注意:tags必须严格按照上述YAML列表格式输出,每个标签以"- "开头,禁止使用逗号分隔或井号标签格式。image: "api"published: 2025-01-14
【当前任务】文件名: {clean_filename}所属分类: {category}文章摘要: {content[:1500]}
请严格按照属性生成规则和示例的顺序(title, description, category, tags, slug, image, published)只输出 YAML 键值对,严禁包含 ```yaml 或 ``` 等标记。"""
payload = { "model": MODEL_NAME, "messages": [{"role": "user", "content": prompt}], "temperature": TEMPERATURE, }
data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(API_URL, data=data) req.add_header("Content-Type", "application/json")
try: with urllib.request.urlopen(req, timeout=300) as response: result = json.loads(response.read().decode("utf-8")) raw_content = result["choices"][0]["message"]["content"].strip() # 过滤掉可能出现的 Markdown 包裹符 return raw_content.replace("```yaml", "").replace("```", "").strip() except Exception as e: print(f"❌ SEO Frontmatter AI请求失败 ({filename}): {e}") return None
# ==================== 目录整理相关函数 ====================
def delete_ignore_files(dir_path, remove_empty_subdirs=True): """ 删除目录中的忽略文件
参数: dir_path: 目录路径 remove_empty_subdirs: 是否删除删除忽略文件后变成空的子目录
返回: 删除的文件数量 """ if not os.path.isdir(dir_path): return 0
# 检查目录是否在忽略列表中 if should_ignore_directory(dir_path): return 0
deleted_count = 0 try: items = os.listdir(dir_path) for item in items: if should_ignore_file(item): item_path = os.path.join(dir_path, item) try: if os.path.isfile(item_path): os.remove(item_path) if DEBUG_MODE: print(f"🗑️ 删除忽略文件: {item_path}") deleted_count += 1 elif os.path.isdir(item_path): # 如果是目录,递归处理忽略文件 deleted_count += delete_ignore_files( item_path, remove_empty_subdirs ) # 删除空目录(如果删除忽略文件后目录变空) if remove_empty_subdirs and is_directory_empty(item_path): try: os.rmdir(item_path) if DEBUG_MODE: print(f"🗑️ 删除空子目录: {item_path}") except Exception as e: print(f"⚠️ 无法删除空子目录 {item_path}: {e}") except Exception as e: print(f"⚠️ 无法删除 {item_path}: {e}") except Exception as e: print(f"⚠️ 无法访问目录 {dir_path}: {e}")
return deleted_count
def is_directory_empty(dir_path): """ 检查目录是否为空(包括只包含忽略文件的目录)
参数: dir_path: 目录路径
返回: True如果目录为空或只包含忽略的文件/空子目录,否则False """ if not os.path.isdir(dir_path): return False
try: items = os.listdir(dir_path) # 调试日志 if DEBUG_MODE: print(f"🔍 检查目录: {dir_path}") print(f" 目录内容: {items}")
# 过滤忽略的文件 filtered_items = [item for item in items if not should_ignore_file(item)] if DEBUG_MODE: print(f" 过滤后内容: {filtered_items}")
# 递归检查每个项目 for item in items: if should_ignore_file(item): if DEBUG_MODE: print(f" 忽略文件: {item}") continue
item_path = os.path.join(dir_path, item) if os.path.isdir(item_path): # 递归检查子目录是否为空 if not is_directory_empty(item_path): return False else: # 发现非忽略文件 if DEBUG_MODE: print(f" 发现非忽略文件: {item}") return False return True except OSError: return False
def remove_empty_dirs(root_path): """ 深度优先递归清理空目录(包括只包含忽略文件的目录)
参数: root_path: 起始目录路径 """ if DEBUG_MODE: print(f"\n🧹 开始递归清理空目录树: {root_path}")
# 首先删除所有忽略文件 if DEBUG_MODE: print("🔍 搜索并删除忽略文件...") total_ignored_files = delete_ignore_files(root_path, True) if total_ignored_files > 0: print(f"🗑️ 删除了 {total_ignored_files} 个系统文件")
cleaned_dirs = 0 errors = []
# 使用后序遍历(深度优先)确保先处理子目录 for dirpath, dirnames, filenames in os.walk(root_path, topdown=False): # 检查目录是否在忽略列表中 if should_ignore_directory(dirpath): continue
# 再次删除该目录中的忽略文件(处理可能新发现的) deleted = delete_ignore_files(dirpath, False) if DEBUG_MODE and deleted > 0: print(f" 在 {dirpath} 中删除了 {deleted} 个忽略文件")
if DEBUG_MODE: # 调试日志 print(f"\n📁 处理目录: {dirpath}") print(f" 子目录: {dirnames}") print(f" 文件: {filenames}")
# 检查当前目录是否为空(考虑忽略的文件) if is_directory_empty(dirpath): # 确保不是源目录本身 if os.path.abspath(dirpath) != os.path.abspath(root_path): # 重试机制:最多尝试3次 max_retries = 3 for attempt in range(max_retries): try: os.rmdir(dirpath) print(f"🗑️ 清理空目录: {dirpath}") cleaned_dirs += 1 break # 成功则跳出重试循环 except OSError as e: if attempt < max_retries - 1: # 不是最后一次尝试,等待后重试 import time
wait_time = 0.5 * (attempt + 1) # 递增等待时间 if DEBUG_MODE: print( f" 重试 {attempt + 1}/{max_retries} - 等待 {wait_time}秒..." ) time.sleep(wait_time) # 再次检查目录是否仍然为空 if not is_directory_empty(dirpath): # 目录不再为空,可能是新文件出现 if DEBUG_MODE: print(f" 目录 {dirpath} 在重试期间不再为空,跳过") break else: # 最后一次尝试失败 error_msg = f"⚠️ 无法删除目录 {dirpath}: {e} (尝试 {max_retries} 次)" if DEBUG_MODE: print(error_msg) errors.append(error_msg) elif DEBUG_MODE: print(f" 目录非空,跳过删除")
# 二次清理:再次扫描整个目录树,确保没有遗漏 if cleaned_dirs > 0: print("\n🔍 二次检查目录树...") additional_cleaned = 0 for dirpath, dirnames, filenames in os.walk(root_path, topdown=False): if should_ignore_directory(dirpath): continue if is_directory_empty(dirpath): if os.path.abspath(dirpath) != os.path.abspath(root_path): try: os.rmdir(dirpath) print(f"🗑️ 二次清理空目录: {dirpath}") additional_cleaned += 1 cleaned_dirs += 1 except OSError as e: # 二次清理中的错误可以更宽容 if DEBUG_MODE: print(f" 二次清理跳过: {dirpath} - {e}") if additional_cleaned > 0: print(f" 📁 二次清理额外清除了 {additional_cleaned} 个目录")
# 显示清理摘要 summary = [] if total_ignored_files > 0: summary.append(f"🗑️ 删除系统文件: {total_ignored_files} 个") if cleaned_dirs > 0: summary.append(f"📁 清理空目录: {cleaned_dirs} 个") if errors: summary.append(f"⚠️ 遇到 {len(errors)} 个错误")
if summary: print("\n🧹 清理摘要:") for line in summary: print(f" {line}")
if DEBUG_MODE: print("✅ 空目录清理完成")
def organize_posts_by_ai(): """ 自动分类整理模式:使用AI对文章进行分类,并移动到对应分类目录 同时更新Frontmatter中的category字段 """ if not os.path.exists(SOURCE_DIR): print(f"❌ 目录不存在: {SOURCE_DIR}") return
# 1. 预存文件列表,避免移动导致遍历混乱 all_files = [] for root, _, files in os.walk(SOURCE_DIR): # 检查目录是否在忽略列表中 if should_ignore_directory(root): print(f"⏭️ 跳过忽略目录: {root}") continue
for file in files: # 修复:不区分大小写匹配 .md 扩展名 if file.lower().endswith(".md"): all_files.append(os.path.join(root, file))
print(f"🚀 开始自动分类处理 {len(all_files)} 个文件...")
processed_count = 0 skipped_count = 0 error_count = 0
for old_path in all_files: try: with open(old_path, "r", encoding="utf-8") as f: raw_text = f.read()
# 提取标题 title_match = re.search(r"^title:\s*(.*)$", raw_text, re.MULTILINE) title = ( title_match.group(1).strip("'\" ") if title_match else os.path.basename(old_path) )
# 获取分类 category = call_classify_ai(title, raw_text) if not category: print(f"⏭️ 跳过(无法分类): {title}") skipped_count += 1 continue
fm_match = re.match(r"^---\s*\n.*?\n---\s*\n", raw_text, re.DOTALL)
if fm_match: fm_text = fm_match.group(0) body_text = raw_text[fm_match.end() :]
if re.search(r"^category:", fm_text, re.MULTILINE): new_fm_text = re.sub( r"^category:.*$", f"category: {category}", fm_text, flags=re.MULTILINE, count=1, ) else: first_field_match = re.search(r"^\w+:", fm_text, re.MULTILINE) if first_field_match: insert_pos = first_field_match.end() line_end = fm_text.find("\n", insert_pos) if line_end != -1: new_fm_text = ( fm_text[:line_end] + f"\ncategory: {category}" + fm_text[line_end:] ) else: new_fm_text = ( fm_text.rstrip("\n-") + f"\ncategory: {category}\n---\n" ) else: new_fm_text = ( fm_text.rstrip("-") + f"category: {category}\n---\n" )
new_text = new_fm_text + body_text else: new_text = f"---\ncategory: {category}\n---\n\n{raw_text}"
with open(old_path, "w", encoding="utf-8") as f: f.write(new_text)
# 准备目标目录 target_dir = os.path.join(SOURCE_DIR, category) if not os.path.exists(target_dir): os.makedirs(target_dir)
# 移动文件到分类目录 new_path = os.path.join(target_dir, os.path.basename(old_path)) if os.path.abspath(old_path) != os.path.abspath(new_path): shutil.move(old_path, new_path) print(f"✅ 分类移动: {title} -> {category}") processed_count += 1 else: print(f"ℹ️ 文件已在目标目录: {title}") processed_count += 1
except Exception as e: print(f"❌ 出错: {old_path} -> {e}") error_count += 1
# 打印处理统计 print(f"\n📊 自动分类处理统计:") print(f" ✅ 成功处理: {processed_count}") print(f" ⏭️ 跳过: {skipped_count}") print(f" ❌ 失败: {error_count}")
return processed_count + skipped_count + error_count > 0 # 返回是否有文件被处理
# ==================== 主处理函数 ====================
def process_files(): """主处理函数:根据配置模式处理所有文件""" if not os.path.exists(SOURCE_DIR): print(f"❌ 路径不存在: {SOURCE_DIR}") return
# 显示配置信息 print("=" * 60) print("📝 增强版 Obsidian SEO Frontmatter 生成器 - 配置信息") print("=" * 60) print(f"📁 源目录: {SOURCE_DIR}") print(f"📂 目录整理模式: {ORGANIZE_MODE}") if ORGANIZE_MODE == "auto": print(f"📊 分类数量: {len(CATEGORIES)}") print(f"📅 日期模式: {DATE_MODE}") if DATE_MODE == "fixed": print(f"📅 固定日期: {FIXED_DATE}") elif DATE_MODE == "random_year": print(f"📅 模式: 近一年内的随机日期") elif DATE_MODE == "current": print(f"📅 模式: 当前日期") print(f"🔄 更新模式: {UPDATE_MODE}") print(f"🌡️ 温度配置: {TEMPERATURE}") print(f"🤖 模型名称: {MODEL_NAME}") print("=" * 60) print()
# 步骤1: 如果启用自动分类模式,先进行文章分类和移动 files_organized = False if ORGANIZE_MODE == "auto": print("📂 开始自动分类整理...") files_organized = organize_posts_by_ai() print("✅ 自动分类整理完成") print()
# 步骤2: 处理所有文件的SEO Frontmatter print("🚀 开始生成/更新SEO Frontmatter...")
# 正则:匹配文件开头的 YAML Frontmatter 块 fm_pattern = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) count = 0
for root, dirs, files in os.walk(SOURCE_DIR): # 检查目录是否在忽略列表中 if should_ignore_directory(root): print(f"⏭️ 跳过忽略目录: {root}") continue
for file in files: if file.lower().endswith(".md"): file_path = os.path.join(root, file)
# 1. 确定分类:根据整理模式选择 if ORGANIZE_MODE == "auto": # 自动模式:使用文件所在目录名(可能已经被移动) category = os.path.basename(root) else: # 默认模式:使用文件所在目录名作为分类 category = os.path.basename(root)
with open(file_path, "r", encoding="utf-8") as f: full_text = f.read()
# 2. 提取现有Frontmatter、published和slug(如果存在) existing_published = None existing_slug = None match_fm = fm_pattern.match(full_text) if match_fm: fm_text = match_fm.group(0) published_val = extract_published_from_frontmatter(fm_text) if published_val and validate_published(published_val): existing_published = published_val # 提取并验证现有slug slug_val = extract_slug_from_frontmatter(fm_text) if slug_val and validate_slug(slug_val): existing_slug = slug_val
# 3. 根据更新模式处理 if UPDATE_MODE == "global": # 全局模式:完全重写所有Frontmatter # - 保留策略:published(如符合格式)和slug(如符合格式要求:只包含英文小写字母、数字和连字符)字段会保留原值 # - 强制更新:updated字段必定更新为当前日期,不管该字段是否已存在 body_content = fm_pattern.sub("", full_text).strip() print(f"🚀 全局模式处理: {file} (分类: {category})") new_fm_text = get_seo_frontmatter_ai(file, category, body_content) if new_fm_text: # 如果存在有效的published,替换新生成的published if existing_published: # 将新Frontmatter中的published替换为existing_published # 使用正则替换 published: 后面的值 new_fm_text = re.sub( r"(published:\s*)[^\n]+", f"\\g<1>{existing_published}", new_fm_text, flags=re.IGNORECASE, ) print(f"📅 保留原published: {existing_published}")
# 如果存在有效的slug,替换新生成的slug if existing_slug: new_fm_text = re.sub( r"(slug:\s*)[^\n]+", f'\\g<1>"{existing_slug}"', new_fm_text, flags=re.IGNORECASE, ) print(f'🔗 保留原slug: "{existing_slug}"') else: # 没有有效的slug,清理slug值,确保没有中文字符 new_fm_text = extract_and_clean_slug(new_fm_text, file)
# 强制添加或更新updated属性为当前日期 current_date = get_current_date() if re.search(r"updated:\s*\S+", new_fm_text, re.IGNORECASE): new_fm_text = re.sub( r"(updated:\s*)[^\n]+", f"\\g<1>{current_date}", new_fm_text, flags=re.IGNORECASE, ) else: # 在published后添加updated new_fm_text = re.sub( r"(published:\s*\S+)", f"\\g<1>\nupdated: {current_date}", new_fm_text, flags=re.IGNORECASE, ) print(f"📅 更新updated: {current_date}")
final_content = f"---\n{new_fm_text}\n---\n\n{body_content}" with open(file_path, "w", encoding="utf-8") as f: f.write(final_content) print(f"✅ 属性已更新: {file}") count += 1 elif UPDATE_MODE == "incremental": # 增量补全模式:只补充缺失的Frontmatter字段 # - 保留策略:published(如符合格式)和slug(如符合格式要求:只包含英文小写字母、数字和连字符)字段会保留原值 # - 强制更新:updated字段必定更新为当前日期,即使Frontmatter已完整 if match_fm: # 有 Frontmatter,检查是否包含必需字段 fm_text = match_fm.group(0) # 简单检查是否存在关键字段(这里简化,实际应解析YAML) required_keys = [ "title", "description", "tags", "slug", "image", "published", ] missing_keys = [] for key in required_keys: if key + ":" not in fm_text: missing_keys.append(key)
if missing_keys: print( f"🔍 增量模式处理: {file} (分类: {category}) - 缺失字段: {missing_keys}" ) body_content = fm_pattern.sub("", full_text).strip() new_fm_text = get_seo_frontmatter_ai( file, category, body_content ) if new_fm_text: # 如果存在有效的published,替换新生成的published if existing_published: # 替换published new_fm_text = re.sub( r"(published:\s*)[^\n]+", f"\\g<1>{existing_published}", new_fm_text, flags=re.IGNORECASE, ) print(f"📅 保留原published: {existing_published}")
# 如果存在有效的slug,替换新生成的slug if existing_slug: new_fm_text = re.sub( r"(slug:\s*)[^\n]+", f'\\g<1>"{existing_slug}"', new_fm_text, flags=re.IGNORECASE, ) print(f'🔗 保留原slug: "{existing_slug}"') else: # 没有有效的slug,清理slug值,确保没有中文字符 new_fm_text = extract_and_clean_slug( new_fm_text, file )
# 强制添加或更新updated属性为当前日期 current_date = get_current_date() if re.search( r"updated:\s*\S+", new_fm_text, re.IGNORECASE ): new_fm_text = re.sub( r"(updated:\s*)[^\n]+", f"\\g<1>{current_date}", new_fm_text, flags=re.IGNORECASE, ) else: # 在published后添加updated new_fm_text = re.sub( r"(published:\s*\S+)", f"\\g<1>\nupdated: {current_date}", new_fm_text, flags=re.IGNORECASE, ) print(f"📅 更新updated: {current_date}")
final_content = ( f"---\n{new_fm_text}\n---\n\n{body_content}" ) with open(file_path, "w", encoding="utf-8") as f: f.write(final_content) print(f"✅ 属性已补全: {file}") count += 1 else: # Frontmatter 已完整,但仍然需要强制更新updated print( f"🔍 增量模式处理: {file} (分类: {category}) - Frontmatter 已完整,强制更新updated" ) current_date = get_current_date()
# 检查是否存在updated属性 if re.search(r"updated:\s*\S+", fm_text, re.IGNORECASE): # 替换existing updated updated_fm_text = re.sub( r"(updated:\s*)[^\n]+", f"\\g<1>{current_date}", fm_text, flags=re.IGNORECASE, ) final_content = updated_fm_text + fm_pattern.sub( "", full_text ) else: # 在published后添加updated updated_fm_text = re.sub( r"(published:\s*\S+)", f"\\g<1>\nupdated: {current_date}", fm_text, flags=re.IGNORECASE, ) final_content = updated_fm_text + fm_pattern.sub( "", full_text )
with open(file_path, "w", encoding="utf-8") as f: f.write(final_content) print(f"📅 更新updated: {current_date}") print(f"✅ 属性已更新: {file}") count += 1 else: # 没有 Frontmatter,生成完整属性 print( f"🔍 增量模式处理: {file} (分类: {category}) - 无Frontmatter,生成完整属性" ) new_fm_text = get_seo_frontmatter_ai( file, category, full_text.strip() ) if new_fm_text: # 无Frontmatter时直接清理slug,不检查existing_slug new_fm_text = extract_and_clean_slug(new_fm_text, file) print(f"✨ 生成新slug(基于AI)")
# 强制添加或更新updated属性为当前日期 current_date = get_current_date() if re.search(r"updated:\s*\S+", new_fm_text, re.IGNORECASE): new_fm_text = re.sub( r"(updated:\s*)[^\n]+", f"\\g<1>{current_date}", new_fm_text, flags=re.IGNORECASE, ) else: # 在published后添加updated new_fm_text = re.sub( r"(published:\s*\S+)", f"\\g<1>\nupdated: {current_date}", new_fm_text, flags=re.IGNORECASE, ) print(f"📅 更新updated: {current_date}")
final_content = ( f"---\n{new_fm_text}\n---\n\n{full_text.strip()}" ) with open(file_path, "w", encoding="utf-8") as f: f.write(final_content) print(f"✅ 属性已生成: {file}") count += 1 else: print(f"❌ 未知更新模式: {UPDATE_MODE}") return
print(f"\n✨ SEO Frontmatter 处理完成!共覆盖更新 {count} 个文件。")
# 步骤3: 清理空目录(如果进行了文件移动或希望保持整洁) if files_organized or DEBUG_MODE: print("\n🧹 开始清理空目录...") remove_empty_dirs(SOURCE_DIR) print("✅ 目录清理完成") else: print("\nℹ️ 未进行文件移动,跳过空目录清理")
# ==================== 主程序入口 ====================if __name__ == "__main__": process_files()📋 配置引导
| 关键配置项\场景 | AI分类+SEO | 侧重AI分类 | 保留原分类 |
|---|---|---|---|
| ORGANIZE_MODE 配置 | "auto" | "auto" | "default" |
| UPDATE_MODE 配置 | "global" | "incremental" | "incremental" |
| CATEGORIES 列表配置 | 需完整配置 | 需完整配置 | 无需配置 |
🎯 场景说明
- AI分类+SEO:智能分类 + SEO优化,适合新博客建立
- 侧重AI分类:优先确保分类准确,适合内容整理需求
- 保留当前分类:保持现有目录结构,适合日常维护
🧠 关于预定义的分类列表的思路分享: 将你尽可能多的文章名称以列表形式提交给 DeepSeek 让 AI 大模型帮你设计一个分类目录框架结构,此时可将分类目录填入到配置项中,使用脚本调用模型自动对文章进行分类匹配,这是一个有比较高目标导向的方案和思路,供大家参考。
🚀 使用指南
⚠️ 重要安全提示
⚠️ 数据备份:首次运行前请务必备份原始笔记数据 ⚠️ 测试运行:建议先在小型测试目录中运行,验证效果 ⚠️ AI 服务稳定性:确保本地 AI 服务稳定运行,避免处理中断
第一步:环境准备
- 确保 Python 3.7+ 已安装
- 启动本地 AI 服务(如 Ollama/LMStudio 等兼容OpenAI API的服务)
- 确认 AI 服务地址与脚本配置一致
第二步:配置调整
- 打开
obsdian-seo-plus-enhanced.py - 根据你的需求参考配置说明修改配置项
- 特别关注:
SOURCE_DIR:设置正确的笔记目录路径CATEGORIES:调整分类列表匹配你的内容领域ORGANIZE_MODE:选择合适的整理模式UPDATE_MODE:选择适合的更新策略API_URL:正确填写本地AI服务地址,支持Ollama、LM Studio等兼容OpenAI API的服务MODEL_NAME:正确填写模型名称
第三步:脚本运行
# 直接运行python3 obsdian-seo-plus-enhanced.py
# 或添加执行权限后运行chmod +x obsdian-seo-plus-enhanced.py./obsdian-seo-plus-enhanced.py第四步:处理流程
脚本执行后将显示:
============================================================📝 增强版 Obsidian SEO Frontmatter 生成器 - 配置信息============================================================📁 源目录: ./WEBNOTE📂 目录整理模式: auto📊 分类数量: 11📅 日期模式: current📅 模式: 当前日期🔄 更新模式: global🌡️ 温度配置: 0.3🤖 模型名称: qwen3-vl-30b-a3b-instruct-mlx============================================================
📂 开始自动分类整理...🚀 开始自动分类处理 1 个文件...第五步:结果验证
以下是导出的 Markdown 文件中的 Frontmatter 示例:
---title: "Markdown SEO Frontmatter 批量生成器"description: "如何高效将Obsidian笔记转换为Astro博客?本文提供自动化工具,支持AI智能分类与SEO属性生成,一键批量处理Markdown文件并适配Firefly主题,解决内容迁移痛点。"category: Astro教程tags: - 自媒体运营 - 实战复盘 - SEO优化slug: "markdown-seo-frontmatter-batch-generator"image: "api"published: 2026-01-13updated: 2026-01-15---- 检查文件是否按分类移动到正确目录
- 验证 Frontmatter 是否完整生成
- 查看生成的 SEO 属性是否符合预期
🔍 常见问题
🛠️ 使用与优化类
-
🏷️ 如何提高文章分类的准确性?
- 工具默认使用极低的AI温度参数(0.01),以保障分类结果稳定。若效果不佳,建议尝试优化
SYSTEM_PROMPT中的指令,并确保分类列表能覆盖所有文章类型,避免产生过多的“未分类”结果。
- 工具默认使用极低的AI温度参数(0.01),以保障分类结果稳定。若效果不佳,建议尝试优化
-
🛠️ 如何优化生成的文章元数据(Frontmatter)?
- 工具采用三层标签法生成标签,有助于提升SEO效果。同时,在增量更新文章时,工具会智能保留已有的有效发布日期。
-
⚡ 如何处理大量文件?工具的性能如何?
- 工具支持数百个文件的批量处理。为提高效率,AI接口调用的超时时间设置为300秒,并且单个文件的处理失败不会影响其他文件的正常处理。
📄 文件与格式类
-
📝 工具支持什么样的文件格式?
- 支持处理标准语法的Markdown文件,并会生成YAML格式的Frontmatter,确保与Astro等静态站点生成器完美兼容。
-
🔤 生成的文件路径(Slug)和编码格式有要求吗?
- 工具会自动清理Slug中的中文字符,确保其符合Astro的路径规范。所有文件均使用UTF-8编码,以保证中文及其他语言字符的正常显示。
🐛 故障排除类
-
🤖 AI服务调用失败或超时怎么办?
- 请首先检查您本地的AI服务(如Ollama)是否正在运行,并确认脚本中配置的API地址和端口是否正确。
-
🔒 脚本运行时提示文件权限或编码错误?
- 权限问题:请确保运行脚本的用户对需要读取的源目录和写入的目标目录均具备相应的读写权限。
- 编码问题:如果遇到乱码或编码错误,请检查您的原始Markdown文件是否采用UTF-8编码格式保存。
🌟 写在最后
这个脚本的设计初衷是为了解决从个人笔记到公开博客迁移过程中的繁琐工作。通过 AI 自动化 和 智能分类,大幅减少了手动整理的时间成本,让你更专注于内容创作本身。
推荐文章
基于标签匹配 · 智能推荐支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!
喵斯基部落