OpenCode 实践课:4 分钟用 Python 写出一个命令行 Markdown 死链检测工具

项目介绍

写 Markdown 文档时,引用外部链接是常有的事。但随着时间推移,这些链接很可能悄悄失效,让你的文档变成"死链坟墓"。

今天我们用 OpenCode 写一个命令行工具,自动扫描指定目录下所有 .md 文件,提取 HTTP 链接,然后并发检测每个链接的可访问性。最终代码约 90 行,技术栈为 Python + asyncio + aiohttp。

运行效果:

扫描完成: 找到 12 个 Markdown 文件, 28 个唯一链接
正在检测 28 个链接...

============================================================
检测结果: 25 正常, 3 失效
============================================================

❌ 失效链接:
  [旧版文档](https://example.com/old-doc)
    文件: docs/guide.md
    耗时: 2100ms

准备工作

  • Python 3.8+
  • 安装 OpenCode(参考 opencode.ai
  • 安装 aiohttp:pip install aiohttp

打开终端,进入项目目录,启动 OpenCode:

mkdir deadlink-checker && cd deadlink-checker
opencode

实践过程

第一步:一句话启动项目

进入 OpenCode 交互界面后,直接用自然语言描述需求。不需要写任何技术细节,OpenCode 会自动选择最佳实现方案。

我对 OpenCode 说:

> 帮我用 Python 写一个命令行工具,功能是扫描指定目录下所有 Markdown 文件(递归),提取里面所有 HTTP/HTTPS 链接,然后用 asyncio + aiohttp 并发检测每个链接是否可访问。最终输出一个报告,列出正常链接和失效链接。

OpenCode 的响应:

OpenCode 先分析了需求,然后自动创建了项目文件 deadlink.py。它选用了 pathlib.Path.rglob 做递归文件搜索,re 模块做 Markdown 链接正则匹配(\[([^\]]*)\]\(([^)]+)\)),aiohttp 做并发 HTTP 请求。

关键代码结构:

import re
import sys
import asyncio
from pathlib import Path
from urllib.parse import urlparse
import aiohttp

LINK_PATTERN = re.compile(r'\[([^\]]*)\]\(([^)]+)\)')

def find_md_files(root: Path) -> list[Path]:
    return list(root.rglob("*.md"))

def extract_links(content: str) -> list[tuple[str, str]]:
    links = []
    for match in LINK_PATTERN.finditer(content):
        text, url = match.groups()
        if urlparse(url).scheme in ("http", "https"):
            links.append((text, url))
    return links

要点: OpenCode 不需要你告诉它用哪个库、怎么写正则。你描述"做什么",它决定"怎么做"。如果生成的方案不是你想要的,直接说"换个方式",它就会调整。

第二步:加上命令行参数

基础功能跑通后,我让 OpenCode 加上参数解析,让工具更灵活。

我对 OpenCode 说:

> 加上 argparse 命令行参数,支持三个选项:扫描路径(默认当前目录)、请求超时秒数(默认 10)、并发请求数(默认 10)。

OpenCode 的响应:

OpenCode 直接在 main() 函数顶部插入了 argparse.ArgumentParser,并为三个参数设置了合理的默认值和帮助文本。

parser = argparse.ArgumentParser(description="Markdown 死链检测工具")
parser.add_argument("path", nargs="?", default=".", help="要扫描的目录路径")
parser.add_argument("-t", "--timeout", type=int, default=10, help="请求超时秒数")
parser.add_argument("-c", "--concurrency", type=int, default=10, help="并发请求数")
args = parser.parse_args()

要点: 这是 OpenCode 的优势场景——给已有代码"加功能"。它会自动理解现有代码结构,把新逻辑嵌到正确位置,不会破坏已有功能。你只需描述要什么参数,OpenCode 搞定命名、类型、默认值和帮助文本。

第三步:实现异步并发检测

核心性能在并发检测。我让 OpenCode 用 asyncio.gather 并发执行所有 HTTP HEAD 请求。

我对 OpenCode 说:

> 实现 check_link 和 check_links 两个 async 函数。check_link 用 HEAD 请求检测单个链接,返回 URL、状态码和响应时间(毫秒)。check_links 收集所有去重后的 URL,用 asyncio.gather 并发执行。设置合理的 User-Agent,避免被拒绝。

OpenCode 的响应:

async def check_link(session: aiohttp.ClientSession, url: str, timeout: int) -> tuple[str, int, float]:
    import time
    start = time.time()
    try:
        async with session.head(url, timeout=aiohttp.ClientTimeout(total=timeout),
                                 allow_redirects=True) as resp:
            elapsed = (time.time() - start) * 1000
            return url, resp.status, elapsed
    except Exception:
        elapsed = (time.time() - start) * 1000
        return url, -1, elapsed

async def check_links(urls: set[str], timeout: int, concurrency: int) -> dict[str, tuple[int, float]]:
    connector = aiohttp.TCPConnector(limit=concurrency)
    async with aiohttp.ClientSession(connector=connector,
                                      headers={"User-Agent": "DeadLinkChecker/1.0"}) as session:
        tasks = [check_link(session, url, timeout) for url in urls]
        results = await asyncio.gather(*tasks)
    return {url: (status, elapsed) for url, status, elapsed in results}

要点: 注意 OpenCode 自动处理了多个细节——allow_redirects=True 跟踪重定向、TCPConnector(limit=concurrency) 控制并发上限、异常捕获返回 -1 状态码标记失效链接、毫秒级的耗时计算。这些"最佳实践"级别的细节,如果手写需要查文档、试错,但 OpenCode 一次性就做对了。

第四步:美化输出

功能完整了,最后让输出更易读。

我对 OpenCode 说:

> 优化输出格式:先打印扫描统计(文件数、链接数),然后打印分隔线,失效链接用 ❌ 标记并显示来源文件路径,正常链接用 ✅ 标记并显示状态码和响应时间。有任何失效链接时退出码为 1。

OpenCode 的响应:

OpenCode 重组了输出逻辑,添加了统计信息、Unicode 标记和分组显示,并在末尾根据结果设置 sys.exit(1)。最终输出清晰直观,一眼就能看出哪些链接出了问题、从哪个文件引用、响应耗时多少。

完整代码见下一节。

完整代码

#!/usr/bin/env python3
"""Markdown 死链检测工具"""

import re
import sys
import time
import asyncio
import argparse
from pathlib import Path
from urllib.parse import urlparse

import aiohttp

LINK_PATTERN = re.compile(r'\[([^\]]*)\]\(([^)]+)\)')


def find_md_files(root: Path) -> list[Path]:
    return list(root.rglob("*.md"))


def extract_links(content: str) -> list[tuple[str, str]]:
    links = []
    for match in LINK_PATTERN.finditer(content):
        text, url = match.groups()
        if urlparse(url).scheme in ("http", "https"):
            links.append((text, url))
    return links


async def check_link(session: aiohttp.ClientSession, url: str,
                     timeout: int) -> tuple[str, int, float]:
    start = time.time()
    try:
        async with session.head(
            url,
            timeout=aiohttp.ClientTimeout(total=timeout),
            allow_redirects=True,
        ) as resp:
            elapsed = (time.time() - start) * 1000
            return url, resp.status, elapsed
    except Exception:
        elapsed = (time.time() - start) * 1000
        return url, -1, elapsed


async def check_links(
    urls: set[str], timeout: int, concurrency: int
) -> dict[str, tuple[int, float]]:
    connector = aiohttp.TCPConnector(limit=concurrency)
    headers = {"User-Agent": "DeadLinkChecker/1.0"}
    async with aiohttp.ClientSession(
        connector=connector, headers=headers
    ) as session:
        tasks = [check_link(session, url, timeout) for url in urls]
        results = await asyncio.gather(*tasks)
    return {url: (status, elapsed) for url, status, elapsed in results}


async def main():
    parser = argparse.ArgumentParser(description="Markdown 死链检测工具")
    parser.add_argument("path", nargs="?", default=".",
                        help="要扫描的目录路径 (默认: 当前目录)")
    parser.add_argument("-t", "--timeout", type=int, default=10,
                        help="请求超时秒数 (默认: 10)")
    parser.add_argument("-c", "--concurrency", type=int, default=10,
                        help="并发请求数 (默认: 10)")
    args = parser.parse_args()

    root = Path(args.path)
    if not root.exists():
        print(f"错误: 目录不存在 - {root}")
        sys.exit(1)

    md_files = find_md_files(root)
    if not md_files:
        print("未找到 Markdown 文件")
        return

    all_links: dict[str, list[tuple[str, str, Path]]] = {}
    for md_file in md_files:
        for text, url in extract_links(
            md_file.read_text(encoding="utf-8", errors="ignore")
        ):
            all_links.setdefault(url, []).append((text, url, md_file))

    if not all_links:
        print("未找到 HTTP 链接")
        return

    print(f"扫描完成: 找到 {len(md_files)} 个 Markdown 文件, "
          f"{len(all_links)} 个唯一链接")
    print(f"正在检测 {len(all_links)} 个链接...\n")

    results = await check_links(set(all_links.keys()),
                                args.timeout, args.concurrency)

    broken, ok_list = [], []
    for url, (status, elapsed) in results.items():
        if status == -1:
            broken.append((url, elapsed))
        else:
            ok_list.append((url, status, elapsed))

    print(f"{'=' * 60}")
    print(f"检测结果: {len(ok_list)} 正常, {len(broken)} 失效")
    print(f"{'=' * 60}\n")

    if broken:
        print("❌ 失效链接:")
        for url, elapsed in broken:
            for text, _, src_file in all_links[url]:
                print(f"  [{text}]({url})")
                print(f"    文件: {src_file}")
                print(f"    耗时: {elapsed:.0f}ms\n")

    if ok_list:
        print("✅ 正常链接:")
        for url, status, elapsed in ok_list:
            print(f"  [{status}] {url} ({elapsed:.0f}ms)")

    if broken:
        sys.exit(1)


if __name__ == "__main__":
    asyncio.run(main())

小结

整个开发过程只用了 4 次自然语言交互,每次都是在已有代码上增量添加功能:

第一次:描述整体需求 → 生成完整骨架

第二次:加命令行参数 → 自动嵌入正确位置

第三次:实现异步并发 → 附带最佳实践细节

第四次:美化输出 → 重组逻辑、添加退出码

对比传统开发方式:手动查 aiohttp API、编写正则、处理异常边界、设计输出格式——至少 15-20 分钟。而用 OpenCode,你只需思考"要什么",把"怎么做"交给 AI。整个过程就像和一位熟悉 Python 生态的同事结对编程,效率提升明显。

下次你的 Markdown 文档发现死链,试试这个工具,也试试用 OpenCode 自己写一个。