Files
xiaoli-cli/plugins/markdown_tool.py
T
shuiyu1123 78fea7ccdd xiaoli-cli 7.0.0: 重构底层架构 + 移除工具截断
- 重构 core/ 底层 (7文件): app/runner/engine/parser/history/safety/display
- FC原生调用为主,prompt-JSON保底
- 移除所有工具返回截断 (ai_search/git_tools/cmd_executor/browser_auto/network_tools/sub_agent/tool_search)
- 删除无用文件 (旧服务器/WebUI/彩蛋/启动器等21个文件)
- 清理 constants.py 死代码
- 版本号更新为 7.0.0
2026-07-05 13:17:27 +08:00

541 lines
20 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Markdown 工具插件 - Markdown 渲染、目录生成、格式转换
支持 TOC 生成、标题提取、表格生成、格式化、统计
"""
import re
import json
import os
from typing import List, Tuple
class Liugin:
"""Markdown 工具 - TOC/渲染/格式转换/统计"""
def __init__(self):
self.usage = """Markdown 工具
操作:
toc <文件或Markdown> - 生成目录 (Table of Contents)
headings <文件或Markdown> - 提取所有标题
stats <文件或Markdown> - 统计信息(字数/行数/段落数等)
table <数据> - 从 JSON 数组生成 Markdown 表格
checklist <文件> - 提取所有待办事项
links <文件或Markdown> - 提取所有链接
images <文件或Markdown> - 提取所有图片
frontmatter <文件> - 提取 YAML frontmatter
format <文件或Markdown> - 格式化(标题层级、列表缩进等)
wordcount <文件或Markdown> - 详细字数统计
outline <文件或Markdown> - 生成大纲视图
lint <文件> - Markdown 语法检查
示例:
markdown_tool toc README.md
markdown_tool stats article.md
markdown_tool table '[{"name":"Alice","age":25},{"name":"Bob","age":30}]'
markdown_tool checklist TODO.md
markdown_tool links README.md
markdown_tool lint README.md
"""
self.cli = None
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "markdown_tool",
"description": "Markdown 工具 - TOC 生成、标题提取、统计、表格生成、语法检查",
"keywords": ["markdown", "md", "目录", "toc", "标题", "表格", "统计", "链接", "检查"],
"usage": self.usage
}
def get_mcp_definition(self):
return {
"name": "markdown_tool",
"description": "Markdown 工具,支持 TOC、统计、表格生成、链接提取、语法检查",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["toc", "headings", "stats", "table", "checklist",
"links", "images", "frontmatter", "format",
"wordcount", "outline", "lint"],
"description": "操作类型"
},
"input": {"type": "string", "description": "Markdown 文件路径或内容"},
"data": {"type": "string", "description": "JSON 数据(table 操作用)"}
},
"required": ["operation", "input"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
inp = arguments.get("input", "")
return f"{op} {inp}".strip()
def _load_content(self, text: str) -> Tuple[str, str]:
"""加载内容(文件路径或文本)"""
if os.path.isfile(text):
with open(text, 'r', encoding='utf-8') as f:
return f.read(), f"文件: {text}"
return text, "文本输入"
def handle(self, args: str) -> str:
try:
parts = self._parse_args(args)
if not parts:
return "错误:请提供操作类型"
operation = parts[0].lower()
handlers = {
"toc": self._op_toc,
"headings": self._op_headings,
"stats": self._op_stats,
"table": self._op_table,
"checklist": self._op_checklist,
"links": self._op_links,
"images": self._op_images,
"frontmatter": self._op_frontmatter,
"format": self._op_format,
"wordcount": self._op_wordcount,
"outline": self._op_outline,
"lint": self._op_lint,
}
handler = handlers.get(operation)
if not handler:
return f"错误:不支持的操作 '{operation}'"
return handler(parts[1:])
except Exception as e:
return f"Markdown 工具错误: {str(e)}"
def _parse_args(self, args: str) -> List[str]:
parts, current, in_quotes, qc = [], "", False, None
for ch in args:
if ch in ('"', "'") and not in_quotes:
in_quotes, qc = True, ch
elif ch == qc and in_quotes:
in_quotes, qc = False, None
elif ch == ' ' and not in_quotes:
if current:
parts.append(current)
current = ""
else:
current += ch
if current:
parts.append(current)
return parts
def _extract_headings(self, content: str) -> List[Tuple[int, str, str]]:
"""提取标题: [(level, text, slug)]"""
headings = []
in_code = False
for line in content.split('\n'):
if line.strip().startswith('```'):
in_code = not in_code
continue
if in_code:
continue
match = re.match(r'^(#{1,6})\s+(.+)', line)
if match:
level = len(match.group(1))
text = match.group(2).strip()
# 生成 slug
slug = re.sub(r'[^\w\s-]', '', text.lower())
slug = re.sub(r'\s+', '-', slug.strip())
headings.append((level, text, slug))
return headings
def _op_toc(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
headings = self._extract_headings(content)
if not headings:
return "📭 未找到标题"
result = f" 目录 ({source}):\n"
result += "─" * 40 + "\n"
for level, text, slug in headings:
indent = " " * (level - 1)
result += f"{indent}- [{text}](#{slug})\n"
return result.rstrip()
def _op_headings(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
headings = self._extract_headings(content)
if not headings:
return "📭 未找到标题"
result = f" 标题列表 ({len(headings)} 个):\n"
for level, text, _ in headings:
prefix = "#" * level
result += f" {prefix:8s} {text}\n"
return result.rstrip()
def _op_stats(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
lines = content.split('\n')
total_lines = len(lines)
blank_lines = sum(1 for l in lines if not l.strip())
code_blocks = content.count('```') // 2
headings = len(self._extract_headings(content))
links = len(re.findall(r'\[([^\]]+)\]\([^)]+\)', content))
images = len(re.findall(r'!\[([^\]]*)\]\([^)]+\)', content))
# 中文字符
chinese = len(re.findall(r'[\u4e00-\u9fff]', content))
# 英文单词
english = len(re.findall(r'[a-zA-Z]+', content))
# 段落数(连续非空行)
paragraphs = 0
in_para = False
for l in lines:
if l.strip():
if not in_para:
paragraphs += 1
in_para = True
else:
in_para = False
result = f"📊 Markdown 统计 ({source})\n"
result += "─" * 35 + "\n"
result += f" 总行数: {total_lines}\n"
result += f" 空行: {blank_lines}\n"
result += f" 段落: {paragraphs}\n"
result += f" 代码块: {code_blocks}\n"
result += f" 标题: {headings}\n"
result += f" 链接: {links}\n"
result += f" 图片: {images}\n"
result += f" 中文字: {chinese}\n"
result += f" 英文词: {english}\n"
result += f" 字符数: {len(content)}\n"
return result.rstrip()
def _op_table(self, args: List[str]) -> str:
if not args:
return "错误:请提供 JSON 数组"
data_str = args[0]
try:
data = json.loads(data_str)
except json.JSONDecodeError as e:
return f"❌ JSON 解析错误: {e}"
if not isinstance(data, list) or not data:
return "❌ 需要非空 JSON 数组"
if not isinstance(data[0], dict):
return "❌ 数组元素必须是对象"
headers = list(data[0].keys())
# 计算列宽
widths = {h: len(h) for h in headers}
for row in data:
for h in headers:
widths[h] = max(widths[h], len(str(row.get(h, ""))))
# 生成表格
header_line = "| " + " | ".join(h.ljust(widths[h]) for h in headers) + " |"
sep_line = "| " + " | ".join("-" * widths[h] for h in headers) + " |"
rows = []
for row in data:
rows.append("| " + " | ".join(str(row.get(h, "")).ljust(widths[h]) for h in headers) + " |")
result = f" Markdown 表格 ({len(data)} 行):\n\n"
result += header_line + "\n" + sep_line + "\n" + "\n".join(rows)
return result
def _op_checklist(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件"
content, source = self._load_content(args[0])
todos = []
done = []
for i, line in enumerate(content.split('\n'), 1):
match = re.match(r'^(\s*)[-*]\s*\[([ xX])\]\s*(.+)', line)
if match:
indent = match.group(1)
checked = match.group(2).lower() == 'x'
text = match.group(3).strip()
if checked:
done.append((i, text))
else:
todos.append((i, text))
result = f" 待办事项 ({source}):\n"
result += "─" * 45 + "\n"
result += f" ☑ 已完成: {len(done)} 个\n"
result += f" 未完成: {len(todos)} 个\n"
result += "─" * 45 + "\n"
if todos:
result += "\n 未完成:\n"
for line_no, text in todos[:30]:
result += f" L{line_no:4d} {text}\n"
if done:
result += "\n 已完成:\n"
for line_no, text in done[:20]:
result += f" L{line_no:4d} {text}\n"
return result.rstrip()
def _op_links(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
links = re.findall(r'\[([^\]]+)\]\(([^)]+)\)', content)
if not links:
return f"📭 未找到链接 ({source})"
result = f" 链接列表 ({len(links)} 个, {source}):\n"
result += "─" * 50 + "\n"
seen = set()
for text, url in links:
if url not in seen:
seen.add(url)
result += f" {text[:30]:30s} → {url}\n"
return result.rstrip()
def _op_images(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
images = re.findall(r'!\[([^\]]*)\]\(([^)]+)\)', content)
if not images:
return f"📭 未找到图片 ({source})"
result = f" ️ 图片列表 ({len(images)} 个, {source}):\n"
result += "─" * 50 + "\n"
seen = set()
for alt, url in images:
if url not in seen:
seen.add(url)
result += f" {alt[:25]:25s} → {url}\n"
return result.rstrip()
def _op_frontmatter(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件"
content, source = self._load_content(args[0])
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not match:
return f"📭 未找到 frontmatter ({source})"
fm = match.group(1)
result = f" Frontmatter ({source}):\n"
result += "─" * 35 + "\n"
result += fm
return result
def _op_format(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
lines = content.split('\n')
formatted = []
prev_blank = False
for line in lines:
stripped = line.strip()
# 确保标题前后有空行
if re.match(r'^#{1,6}\s', stripped):
if formatted and not prev_blank:
formatted.append('')
formatted.append(stripped)
prev_blank = False
continue
# 确保列表项格式一致
if re.match(r'^[-*]\s', stripped):
formatted.append(re.sub(r'^[-*]\s', '- ', stripped))
prev_blank = False
continue
# 去除多余空行
if not stripped:
if not prev_blank:
formatted.append('')
prev_blank = True
continue
prev_blank = False
formatted.append(line.rstrip())
# 确保文件末尾有换行
while formatted and not formatted[-1]:
formatted.pop()
formatted.append('')
return f"✅ 格式化完成 ({source}):\n" + '\n'.join(formatted)
def _op_wordcount(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
# 移除代码块
no_code = re.sub(r'```[\s\S]*?```', '', content)
# 移除行内代码
no_code = re.sub(r'`[^`]+`', '', no_code)
# 移除链接标记
no_code = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', no_code)
# 移除图片标记
no_code = re.sub(r'!\[([^\]]*)\]\([^)]+\)', '', no_code)
# 移除标题标记
no_code = re.sub(r'^#{1,6}\s+', '', no_code, flags=re.MULTILINE)
# 移除加粗/斜体标记
no_code = re.sub(r'\*{1,2}([^*]+)\*{1,2}', r'\1', no_code)
chinese = len(re.findall(r'[\u4e00-\u9fff]', no_code))
english_words = re.findall(r'[a-zA-Z]+', no_code)
english = len(english_words)
total_chars = len(no_code.strip())
# 阅读时间估算(中文 300字/分钟, 英文 200词/分钟)
read_time_cn = chinese / 300
read_time_en = english / 200
read_time = read_time_cn + read_time_en
result = f" 详细字数统计 ({source}):\n"
result += "─" * 35 + "\n"
result += f" 中文字数: {chinese}\n"
result += f" 英文单词: {english}\n"
result += f" 总字符数: {total_chars}\n"
result += f" 预计阅读: {read_time:.1f} 分钟\n"
return result.rstrip()
def _op_outline(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
headings = self._extract_headings(content)
if not headings:
return "📭 未找到标题"
result = f" 大纲视图 ({source}):\n"
result += "═" * 40 + "\n"
for level, text, _ in headings:
if level == 1:
result += f"\n {text}\n"
result += "─" * 30 + "\n"
elif level == 2:
result += f" {text}\n"
elif level == 3:
result += f" {text}\n"
else:
result += f" {'·' * (level - 3)} {text}\n"
return result.rstrip()
def _op_lint(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件"
file_path = args[0]
if not os.path.isfile(file_path):
return f"❌ 文件不存在: {file_path}"
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
lines = content.split('\n')
issues = []
in_code = False
prev_blank = False
heading_levels = []
for i, line in enumerate(lines, 1):
stripped = line.strip()
# 代码块跟踪
if stripped.startswith('```'):
in_code = not in_code
continue
if in_code:
continue
# 检查标题格式
heading_match = re.match(r'^(#{1,6})\s+(.+)', line)
if heading_match:
level = len(heading_match.group(1))
text = heading_match.group(2)
# 检查标题前后空行
if i > 1 and lines[i - 2].strip() and not lines[i - 2].startswith('#'):
issues.append(f" L{i:4d} 标题前缺少空行")
# 检查标题层级跳跃
if heading_levels and level > heading_levels[-1] + 1:
issues.append(f" L{i:4d} 标题层级跳跃 ({heading_levels[-1]} → {level})")
heading_levels.append(level)
# 检查标题末尾空格
if text != text.rstrip():
issues.append(f" L{i:4d} 标题末尾有多余空格")
else:
# 检查行尾空格
if line != line.rstrip() and stripped:
trailing = len(line) - len(line.rstrip())
if trailing >= 2: # 2个以上空格是换行符
pass # 这是合法的 Markdown 换行
elif trailing > 0:
issues.append(f" L{i:4d} 行尾有多余空格")
# 检查连续空行
if not stripped:
if prev_blank:
issues.append(f" L{i:4d} 连续空行")
prev_blank = True
else:
prev_blank = False
# 检查列表格式
list_match = re.match(r'^(\s*)[-*+]\s', line)
if list_match:
indent = len(list_match.group(1))
if indent % 2 != 0 and indent > 0:
issues.append(f" L{i:4d} 列表缩进不是2的倍数")
# 检查链接格式
if re.search(r'\[[^\]]*\]\(\s*\)', line):
issues.append(f" L{i:4d} 链接URL为空")
# 检查重复词
words = stripped.lower().split()
for j in range(len(words) - 1):
if len(words[j]) > 2 and words[j] == words[j + 1]:
issues.append(f" L{i:4d} 重复词: '{words[j]}'")
result = f" ️ Markdown 语法检查 ({file_path}):\n"
result += "─" * 45 + "\n"
if not issues:
result += " 未发现问题!\n"
else:
result += f" 发现 {len(issues)} 个问题:\n\n"
for issue in issues[:50]:
result += f" {issue}\n"
if len(issues) > 50:
result += f"\n ... 还有 {len(issues) - 50} 个问题\n"
return result.rstrip()
def test_plugin():
plugin = Liugin()
print("Markdown 工具测试:")
print(plugin.handle('headings "# Hello\\n## World\\n### Sub"'))
print(plugin.handle('stats "# Hello\\n\\nThis is a test."'))
print(plugin.handle('table \'[{"name":"Alice","age":25},{"name":"Bob","age":30}]\''))
if __name__ == "__main__":
test_plugin()