写 Markdown 文档时,引用外部链接是常有的事。但随着时间推移,这些链接很可能悄悄失效,让你的文档变成"死链坟墓"。
今天我们用 OpenCode 写一个命令行工具,自动扫描指定目录下所有 .md 文件,提取 HTTP 链接,然后并发检测每个链接的可访问性。最终代码约 90 行,技术栈为 Python + asyncio + aiohttp。
运行效果:
扫描完成: 找到 12 个 Markdown 文件, 28 个唯一链接
正在检测 28 个链接...
============================================================
检测结果: 25 正常, 3 失效
============================================================
❌ 失效链接:
[旧版文档](https://example.com/old-doc)
文件: docs/guide.md
耗时: 2100ms
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 自己写一个。