#!/usr/bin/env python3
import os
import re
import argparse
from pathlib import Path
from typing import List, Tuple, Dict, Set
import time
import sys
# 颜色代码,用于终端输出
class Colors:
HEADER = '\033[95m'
BLUE = '\033[94m'
GREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
def log_info(message):
"""输出信息日志"""
print(f"{Colors.BLUE}[INFO]{Colors.ENDC} {message}")
def log_warning(message):
"""输出警告日志"""
print(f"{Colors.WARNING}[WARNING]{Colors.ENDC} {message}")
def log_error(message):
"""输出错误日志"""
print(f"{Colors.FAIL}[ERROR]{Colors.ENDC} {message}")
def log_success(message):
"""输出成功日志"""
print(f"{Colors.GREEN}[SUCCESS]{Colors.ENDC} {message}")
def find_all_md_files(base_dir: str) -> List[Path]:
"""查找指定目录下的所有 .md 和 .mdx 文件"""
md_files = []
base_path = Path(base_dir)
for ext in ["*.md", "*.mdx"]:
md_files.extend(base_path.glob(f"**/{ext}"))
return md_files
def extract_links(file_content: str) -> List[Tuple[str, str, str]]:
"""从文件内容中提取所有链接
返回格式: [(完整匹配文本, 链接文本, 链接URL)]
"""
links = []
# 提取 Markdown 链接 [text](url)
md_links = re.findall(r'\[(.*?)\]\((.*?)\)', file_content)
for text, url in md_links:
full_match = f"[{text}]({url})"
links.append((full_match, text, url))
# 提取 标签链接
a_links = re.findall(r']*?\s+)?href="([^"]*)"[^>]*>(.*?)<\/a>', file_content)
for url, text in a_links:
full_match = f'{text}'
links.append((full_match, text, url))
# 提取 Mintlify Card 组件链接
card_links = re.findall(r']*\s+href="([^"]*)"[^>]*>(.*?)<\/Card>', file_content, re.DOTALL)
for title, url, content in card_links:
full_match = f'{content}'
links.append((full_match, title, url))
return links
def check_link_extensions(links: List[Tuple[str, str, str]],
file_path: Path,
all_files: Dict[str, Path],
base_dir: Path) -> List[Tuple[str, str, str, str]]:
"""检查链接是否包含不需要的扩展名
返回格式: [(完整匹配文本, 链接文本, 原始URL, 修复后URL)]
"""
issues = []
for full_match, text, url in links:
# 忽略外部链接和锚点链接
if url.startswith(('http://', 'https://', '#', 'mailto:', 'tel:')):
continue
# 忽略以 / 开头的绝对路径
if url.startswith('/'):
continue
# 检查链接是否包含 .md 或 .mdx 扩展名
if url.endswith('.md') or url.endswith('.mdx'):
# 计算修复后的 URL
fixed_url = url.rsplit('.', 1)[0]
issues.append((full_match, text, url, fixed_url))
return issues
def fix_links(file_path: Path, issues: List[Tuple[str, str, str, str]], dry_run: bool = True) -> bool:
"""修复文件中的链接问题
Args:
file_path: 文件路径
issues: 需要修复的问题列表 [(完整匹配文本, 链接文本, 原始URL, 修复后URL)]
dry_run: 如果为 True,只显示将要进行的修改,不实际修改文件
Returns:
bool: 是否进行了修改
"""
if not issues:
return False
# 读取文件内容
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
modified_content = content
# 遍历所有问题并修复
for full_match, text, old_url, new_url in issues:
if "Card" in full_match:
# 修复 Card 组件链接
old_pattern = f'href="{old_url}"'
new_pattern = f'href="{new_url}"'
modified_content = modified_content.replace(old_pattern, new_pattern)
elif " 标签链接
old_pattern = f'href="{old_url}"'
new_pattern = f'href="{new_url}"'
modified_content = modified_content.replace(old_pattern, new_pattern)
else:
# 修复 Markdown 链接
old_pattern = f']({old_url})'
new_pattern = f']({new_url})'
modified_content = modified_content.replace(old_pattern, new_pattern)
# 如果内容有变化,写回文件
if modified_content != content and not dry_run:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(modified_content)
return True
return not dry_run and modified_content != content
def process_file(file_path: Path, all_files: Dict[str, Path], base_dir: Path, args):
"""处理单个文件中的链接问题"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
links = extract_links(content)
issues = check_link_extensions(links, file_path, all_files, base_dir)
if issues:
rel_path = file_path.relative_to(base_dir)
print(f"\n{Colors.HEADER}{Colors.BOLD}检查文件: {rel_path}{Colors.ENDC}")
for i, (full_match, text, old_url, new_url) in enumerate(issues, 1):
print(f" {i}. 发现问题: {Colors.WARNING}{old_url}{Colors.ENDC} -> {Colors.GREEN}{new_url}{Colors.ENDC}")
# 询问用户是否修复
if not args.auto_fix:
choice = input(f"\n{Colors.BOLD}修复这些问题? (y/n/a/q): {Colors.ENDC}")
if choice.lower() == 'q': # q 代表退出脚本
log_info("用户请求退出脚本")
sys.exit(0)
elif choice.lower() == 'a': # a 代表全部修复,并设置 auto_fix 标志
args.auto_fix = True
if choice.lower() not in ('y', 'a'):
log_info(f"跳过修复 {rel_path}")
return False
# 修复问题
fixed = fix_links(file_path, issues, dry_run=args.dry_run)
if args.dry_run:
log_info(f"已检测到 {len(issues)} 个需要修复的链接 (模拟运行,实际未修改)")
elif fixed:
log_success(f"已修复 {len(issues)} 个链接问题")
# 如果不是自动修复模式,在每个文件处理完后暂停一下,让用户有时间查看结果
if not args.auto_fix and fixed and not args.dry_run:
input(f"\n{Colors.BOLD}已完成修复,按回车继续下一个文件...{Colors.ENDC}")
return fixed
return False
except Exception as e:
log_error(f"处理文件 {file_path} 时出错: {str(e)}")
return False
def main():
parser = argparse.ArgumentParser(description='检查并修复文档中的链接问题')
parser.add_argument('doc_path', nargs='?', help='文档根目录路径')
parser.add_argument('--dry-run', action='store_true', help='只显示将要修改的内容,不实际修改文件')
parser.add_argument('--auto-fix', action='store_true', help='自动修复所有问题,不询问')
args = parser.parse_args()
# 如果命令行未提供路径,则交互式询问
if args.doc_path is None:
doc_path = input(f"{Colors.BOLD}请输入文档根目录路径: {Colors.ENDC}")
args.doc_path = doc_path.strip()
base_dir = Path(args.doc_path)
if not base_dir.exists() or not base_dir.is_dir():
log_error(f"指定的目录 '{args.doc_path}' 不存在或不是一个目录")
return 1
# 添加确认步骤
print(f"\n{Colors.BOLD}将要扫描的目录:{Colors.ENDC} {Colors.GREEN}{base_dir}{Colors.ENDC}")
if args.dry_run:
print(f"{Colors.BOLD}模式:{Colors.ENDC} {Colors.BLUE}仅检查,不修改文件{Colors.ENDC}")
elif args.auto_fix:
print(f"{Colors.BOLD}模式:{Colors.ENDC} {Colors.BLUE}自动修复所有问题{Colors.ENDC}")
else:
print(f"{Colors.BOLD}模式:{Colors.ENDC} {Colors.BLUE}交互式修复{Colors.ENDC}")
confirm = input(f"\n{Colors.BOLD}确认开始扫描? (y/n): {Colors.ENDC}")
if confirm.lower() != 'y':
log_info("操作已取消")
return 0
log_info(f"开始扫描目录: {base_dir}")
# 查找所有文档文件
all_files_list = find_all_md_files(base_dir)
log_info(f"共找到 {len(all_files_list)} 个文档文件")
# 创建文件路径映射,用于链接验证
all_files = {}
for file_path in all_files_list:
rel_path = file_path.relative_to(base_dir)
all_files[str(rel_path)] = file_path
# 处理所有文件
fixed_count = 0
total_files = len(all_files_list)
try:
for i, file_path in enumerate(all_files_list, 1):
# 清空当前行并显示进度
sys.stdout.write("\r" + " " * 80) # 清空当前行
sys.stdout.write(f"\r{Colors.BOLD}进度: {i}/{total_files} ({i/total_files*100:.1f}%){Colors.ENDC}")
sys.stdout.flush()
# 处理文件,如果有修复则增加计数
if process_file(file_path, all_files, base_dir, args):
fixed_count += 1
except KeyboardInterrupt:
print("\n")
log_warning("用户中断了处理过程")
# 继续执行后面的代码,显示已完成的统计信息
print("\n")
log_info(f"扫描完成,共处理 {total_files} 个文件")
if args.dry_run:
log_info(f"发现 {fixed_count} 个文件中有链接问题需要修复")
else:
log_success(f"已修复 {fixed_count} 个文件中的链接问题")
return 0
if __name__ == "__main__":
sys.exit(main())