OpenCode 实践课:4 分钟用 Python 写出一个命令行 Markdown 代码块提取工具

项目介绍

写技术博客或看文档时,经常会遇到文章里嵌着一段段代码块。想把它们复制出来单独运行或保存,就得一个个手动复制粘贴——很烦。

今天我们用 OpenCode 写一个命令行工具 mdext(Markdown Extract),它能自动扫描 Markdown 文件,把所有代码块按语言提取出来保存为独立文件。纯 Python 实现,零依赖,约 100 行代码。

最终效果mdext tutorial.md -o output/,代码块自动变成 output/tutorial_1.pyoutput/tutorial_2.go 等文件。

准备工作

  • Python 3.8+
  • 安装 OpenCode:访问 opencode.ai 下载安装
  • 配置好终端即可开始

实践过程

第一步:让 OpenCode 生成项目骨架

我的提示词:

> 用 Python 写一个命令行工具,读取 Markdown 文件,用正则提取所有 \\\` 围栏代码块,输出代码块数量、语言和行数摘要。用 argparse,纯标准库,零依赖。

OpenCode 很快就生成了一个基础版本,核心是用 re.findall 匹配代码块:

import re
pattern = r'```(\w+)?\n(.*?)```'
blocks = re.findall(pattern, content, re.DOTALL)

它自动识别到要使用 re.DOTALL 来跨行匹配,还帮我处理了没有指定语言的情况。

要点:描述需求时尽量具体——明确输入(Markdown 文件)、输出(摘要信息)、技术约束(纯标准库、argparse),OpenCode 就能生成更准确的代码。

第二步:添加文件写入功能

我的提示词:

> 现在加上文件保存功能:定义一个语言到扩展名的映射字典(python→.py, go→.go, bash→.sh 等),把每个代码块按 "文件名_编号.扩展名" 保存到指定目录。支持 -o 指定输出目录。

OpenCode 扩展了代码,添加了 LANG_EXT 映射表和文件写入逻辑:

LANG_EXTS = {
    'python': '.py', 'go': '.go', 'rust': '.rs', 'javascript': '.js',
    'typescript': '.ts', 'bash': '.sh', 'sh': '.sh', 'sql': '.sql',
    'json': '.json', 'yaml': '.yml', 'toml': '.toml', 'html': '.html',
    'css': '.css', 'java': '.java', 'cpp': '.cpp', 'c': '.c',
    'php': '.php', 'ruby': '.rb', 'swift': '.swift', 'kotlin': '.kt',
    'xml': '.xml', 'makefile': '', 'dockerfile': '',
}

同时生成了输出目录、写文件的逻辑,并在控制台打印每个文件的保存路径。

要点:分步迭代,每次只提一个明确的功能需求。OpenCode 会在现有代码基础上追加,而不是重写全部。

第三步:增加实用选项

我的提示词:

> 添加 --dry-run 模式(只显示不写入),--stdin 模式(从标准输入读取而不是文件),以及 -n 自定义输出文件名前缀。

OpenCode 一次性加上了三个选项。--dry-run 的实现很简洁——在写文件前加一个条件判断,模式开启时只打印 [DRY-RUN] would write--stdin 模式用 sys.stdin.read() 读取内容,和文件读取逻辑共用后续处理。

parser.add_argument('file', nargs='?', help='Markdown file')
parser.add_argument('--stdin', '-s', action='store_true', help='Read from stdin')
parser.add_argument('-n', '--name', default=None, help='Output file base name')
parser.add_argument('--dry-run', action='store_true', help='Preview only, no file writes')

要点:多个小功能可以一次提,OpenCode 能理解它们是独立的需求并分别实现。但要确保每个功能描述清晰,不给它制造歧义。

第四步:收尾与测试

我的提示词:

> 给主入口加上 if __name__ == '__main__',再补充 argparse 的 description 和 epilog 帮助文字的示例。最后帮我检查有没有边界情况没处理(空文件、无代码块、非法路径)。

OpenCode 补上了入口保护和帮助文档,还加了边界检查——空文件时输出 "No code blocks found.",用 os.makedirs(args.output, exist_ok=True) 确保目标目录存在,用 except FileNotFoundError 捕获文件不存在的异常并给出友好提示。

完整代码

#!/usr/bin/env python3
"""mdext - Extract code blocks from Markdown files."""

import argparse
import os
import re
import sys
from pathlib import Path

LANG_EXTS = {
    'python': '.py', 'py': '.py', 'go': '.go', 'rust': '.rs',
    'javascript': '.js', 'js': '.js', 'typescript': '.ts', 'ts': '.ts',
    'bash': '.sh', 'sh': '.sh', 'sql': '.sql', 'json': '.json',
    'yaml': '.yml', 'yml': '.yml', 'toml': '.toml', 'html': '.html',
    'css': '.css', 'scss': '.scss', 'java': '.java', 'cpp': '.cpp',
    'c': '.c', 'php': '.php', 'ruby': '.rb', 'swift': '.swift',
    'kotlin': '.kt', 'xml': '.xml', 'makefile': '', 'dockerfile': '',
}

BLOCK_RE = re.compile(r'```(\w*)\n(.*?)```', re.DOTALL)


def extract_blocks(content: str) -> list[tuple[str, str]]:
    return [(lang or 'text', code.strip()) for lang, code in BLOCK_RE.findall(content)]


def summary(blocks: list[tuple[str, str]]) -> None:
    print(f'Found {len(blocks)} code block(s):')
    for i, (lang, code) in enumerate(blocks, 1):
        lines = code.count('\n') + 1
        print(f'  [{i}] {lang} → {lines} line(s)')


def save_blocks(blocks: list[tuple[str, str]], output: str, base: str, dry: bool = False) -> None:
    os.makedirs(output, exist_ok=True)
    for i, (lang, code) in enumerate(blocks, 1):
        ext = LANG_EXTS.get(lang.lower(), f'.{lang}' if lang else '.txt')
        path = Path(output) / f'{base}_{i}{ext}'
        if dry:
            print(f'  [DRY-RUN] {path}')
        else:
            path.write_text(code + '\n', encoding='utf-8')
            print(f'  → {path}')


def main() -> None:
    parser = argparse.ArgumentParser(
        description='Extract code blocks from Markdown files.',
        epilog='Example: mdext README.md -o output/',
    )
    parser.add_argument('file', nargs='?', help='Markdown file to process')
    parser.add_argument('-o', '--output', default='.', help='Output directory (default: .)')
    parser.add_argument('-n', '--name', default=None, help='Base name for output files')
    parser.add_argument('-s', '--stdin', action='store_true', help='Read from stdin')
    parser.add_argument('--dry-run', action='store_true', help='Preview only, no writes')
    parser.add_argument('--summary-only', action='store_true', help='Print summary and exit')
    args = parser.parse_args()

    if args.stdin:
        content = sys.stdin.read()
        base_name = args.name or 'extracted'
    elif args.file:
        try:
            content = Path(args.file).read_text(encoding='utf-8')
            base_name = args.name or Path(args.file).stem
        except FileNotFoundError:
            print(f'Error: file not found → {args.file}', file=sys.stderr)
            sys.exit(1)
    else:
        parser.print_help()
        sys.exit(1)

    blocks = extract_blocks(content)
    if not blocks:
        print('No code blocks found.')
        sys.exit(0)

    if args.summary_only:
        summary(blocks)
        sys.exit(0)

    summary(blocks)
    print()
    save_blocks(blocks, args.output, base_name, dry=args.dry_run)
    print(f'\nDone. {len(blocks)} file(s) → {os.path.abspath(args.output)}')


if __name__ == '__main__':
    main()

小结

从零到完整可用的工具,实际和 OpenCode 对话不到 4 分钟。整个过程我只做了三件事:

描述需求——告诉它我要什么(提取代码块、保存为文件)

逐步迭代——每次追加一两个功能,让它基于现有代码扩展

收尾检查——让它补充边界处理和帮助文档

如果手写这段代码,查 argparse 文档、写正则、处理各种边界情况,大概要 20-30 分钟。用 OpenCode 把时间压缩到了几分钟,而且代码质量不比手写差——正确的 re.DOTALL 标志、异常处理、nargs='?' 的用法都自动处理好了。

关键技巧就是把 AI 当成一个随时待命的结对编程搭档——你负责定义"要什么",它负责"怎么实现",你来把关"做得对不对"。