OpenCode 实践课:3 分钟用 Python 写出一个命令行 Markdown 转 HTML 工具

项目介绍

作为一名经常写文档的开发者,你是否遇到过这样的场景:写好了一篇 Markdown 笔记,却需要把它转成 HTML 放到网页上?每次都打开在线转换工具太麻烦,用 Pandoc 又太重。

今天这篇文章,我将用 OpenCode 在 3 分钟内写一个命令行 Markdown 转 HTML 工具。核心技术栈:Python 3 + 标准库(零依赖),最终代码约 110 行,支持标题、段落、链接、图片、代码块、列表和水平分割线。

准备工作

  • Python 3.8+
  • OpenCode 已安装(安装方式见 opencode.ai
  • 一个终端窗口

实践过程

第一步:搭建基本骨架

我先让 OpenCode 帮我把最核心的转换逻辑写出来——处理标题和段落。

我对 OpenCode 说:

> 帮我写一个 Python 脚本,把 Markdown 文本转成 HTML。先实现标题(# 到 ######)和段落的转换。不需要依赖任何第三方库。

OpenCode 立刻生成了一份代码,核心函数 convert_md_to_html() 逐行扫描 Markdown 文本,用正则匹配 # 开头的行作为标题,其余非空行归为段落。

关键要点:这里我只描述了"做什么",没有限定"怎么做"。OpenCode 自己选择了逐行扫描 + 正则匹配的方案,这正是 AI 编程的核心价值——你不需要知道实现细节,只需要清晰表达需求。

第二步:添砖加瓦

骨架有了,但一个实用的转换器至少得支持链接、图片和内联代码。

我对 OpenCode 说:

> 很好!现在加上对内联元素的支持:粗体(text)、斜体(*text*)、行内代码(code)、链接(text)和图片(!alt)。

OpenCode 新增了一个 parse_inline() 函数,用正则表达式依次替换这些 Markdown 内联语法。

关键要点:注意我的提示词是对话式的——"很好!现在加上……"。这很像你和同事结对编程时的交流方式,OpenCode 能理解上下文,在已有代码基础上增量修改。

第三步:完善命令行体验

光有转换函数不够,工具得能从文件读取、向文件输出、处理管道。

我对 OpenCode 说:

> 加上命令行参数解析,支持:md2html input.md 从文件读取,-o output.html 指定输出文件,-w 包裹完整 HTML 文档结构(带基本 CSS 样式),同时支持从 stdin 读取。

这一步 OpenCode 引入 argparse,添加了 --wrap 选项,并在没有输入文件时自动从标准输入读取——这样就能配合管道使用了。

关键要点:OpenCode 自动处理了边界情况:文件编码、管道输入、条件输出。你不会漏掉 encoding='utf-8'if args.output 这种细节。

第四步:补齐格式支持

代码块和列表是 Markdown 的重要组成,缺少它们就不算完整工具。

我对 OpenCode 说:

> 补充支持:围栏代码块(``language ... ``)、无序列表(- * + 开头)、水平分割线(--- *** ___)。

OpenCode 在循环中加入了 in_code_block 状态标记来处理多行代码块,列表项则以 <li> 包裹输出。

关键要点:代码块是最复杂的部分,因为它跨越了多行。我原本以为需要自己描述状态机逻辑,但 OpenCode 自动就用了 in_code_block 标志位——这说明它对这类常见编程模式有很好的"语感"。

第五步:收尾打磨

最后看看有没有遗漏。

我对 OpenCode 说:

> 处理粗斜体组合(*text*),另外代码块未闭合时自动补上闭合标签。

OpenCode 修复了粗体+斜体的正则替换顺序(先匹配 * 再匹配 *,防止被错误截断),并在函数末尾添加了未闭合标签的检查。

整个开发过程就是五句话,OpenCode 每步都在 5-10 秒内完成代码生成。

完整代码

#!/usr/bin/env python3
"""Markdown to HTML converter - zero-dependency CLI tool."""

import re
import sys
import argparse
from pathlib import Path


def parse_inline(text: str) -> str:
    """Convert inline Markdown to HTML."""
    text = re.sub(r'\*\*\*(.+?)\*\*\*', r'<strong><em>\1</em></strong>', text)
    text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
    text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
    text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
    text = re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', r'<img src="\2" alt="\1">', text)
    text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
    return text


def convert_md_to_html(md_text: str) -> str:
    """Convert Markdown text to HTML."""
    lines = md_text.split('\n')
    result = []
    in_code_block = False
    i = 0

    while i < len(lines):
        line = lines[i]

        if line.startswith('```'):
            if in_code_block:
                result.append('</code></pre>')
                in_code_block = False
            else:
                lang = line[3:].strip()
                cls = f' class="language-{lang}"' if lang else ''
                result.append(f'<pre><code{cls}>')
                in_code_block = True
            i += 1
            continue

        if in_code_block:
            result.append(line)
            i += 1
            continue

        heading = re.match(r'^(#{1,6})\s+(.+)$', line)
        if heading:
            level = len(heading.group(1))
            content = parse_inline(heading.group(2))
            result.append(f'<h{level}>{content}</h{level}>')
            i += 1
            continue

        if re.match(r'^(\*{3,}|-{3,}|_{3,})$', line.strip()):
            result.append('<hr>')
            i += 1
            continue

        list_item = re.match(r'^(\s*)[-*+]\s+(.+)$', line)
        if list_item:
            result.append('<ul>')
            while i < len(lines):
                m = re.match(r'^(\s*)[-*+]\s+(.+)$', lines[i])
                if not m:
                    break
                result.append(f'  <li>{parse_inline(m.group(2))}</li>')
                i += 1
            result.append('</ul>')
            continue

        if line.strip() == '':
            i += 1
            continue

        result.append('<p>')
        para_parts = []
        while i < len(lines) and lines[i].strip() and \
                not re.match(r'^(#{1,6})\s+', lines[i]) and \
                not re.match(r'^(\s*)[-*+]\s+', lines[i]) and \
                not re.match(r'^(\*{3,}|-{3,}|_{3,})$', lines[i].strip()) and \
                not lines[i].startswith('```'):
            para_parts.append(parse_inline(lines[i]))
            i += 1
        result.append('<br>\n'.join(para_parts))
        result.append('</p>')

    if in_code_block:
        result.append('</code></pre>')

    return '\n'.join(result)


def main():
    parser = argparse.ArgumentParser(
        description='Convert Markdown to HTML',
        usage='%(prog)s input.md [-o output.html] [-w]'
    )
    parser.add_argument('input', nargs='?', help='Input Markdown file')
    parser.add_argument('-o', '--output', help='Output HTML file')
    parser.add_argument('-w', '--wrap', action='store_true',
                        help='Wrap with full HTML document structure')
    args = parser.parse_args()

    if args.input:
        md_text = Path(args.input).read_text(encoding='utf-8')
    else:
        md_text = sys.stdin.read()

    body = convert_md_to_html(md_text)

    if args.wrap:
        result = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body{{font-family:-apple-system,BlinkMacSystemFont,sans-serif;
max-width:800px;margin:0 auto;padding:2rem;line-height:1.6;color:#333}}
code{{background:#f4f4f4;padding:2px 6px;border-radius:3px}}
pre{{background:#f4f4f4;padding:1rem;border-radius:5px;overflow-x:auto}}
pre code{{background:none;padding:0}}
img{{max-width:100%}}
hr{{border:none;border-top:1px solid #ddd;margin:2rem 0}}
</style>
</head>
<body>
{body}
</body>
</html>"""
    else:
        result = body

    if args.output:
        Path(args.output).write_text(result, encoding='utf-8')
        print(f'✓ Saved to {args.output}')
    else:
        print(result)


if __name__ == '__main__':
    main()

使用方式:

# 文件转文件
python md2html.py readme.md -o readme.html -w

# 管道输入
echo "# Hello **World**" | python md2html.py

# 结合剪切板(macOS)
pbpaste | python md2html.py -w > output.html

小结

整个工具从零到可用的完整代码,我只说了 5 句话,耗时不到 3 分钟。对比传统开发方式,省去了查阅文档、手动敲代码、调试语法的时间。

OpenCode 的核心优势不在它能写出多复杂的代码,而在于你只需要考虑"要什么",不用纠结"怎么写"。这种体验在做这些小工具时尤其明显——想法冒出来,话说完,代码就有了。

对于日常开发中的脚本、工具、原型验证,OpenCode 已经可以替代至少 60% 的手写工作。剩下的 40% 是架构决策和业务逻辑设计——这些恰恰是 AI 暂时无法替代的人类判断力。

*上一篇:OpenCode 实践课:3 分钟用 Go 写出一个命令行时间戳转换工具*