OpenCode 实践课:4 分钟用 Go 写出一个命令行死链检测器

项目介绍

今天用 OpenCode 写一个命令行死链检测器。输入一个网址,程序自动找出页面中的所有链接,并发检查每个链接是否可访问,最后按可用/失效分类展示结果。

  • 技术栈:Go 1.21+
  • 代码行数:约 90 行
  • 核心功能:HTML 链接提取、并发 HTTP 检测、分类输出

这个工具的实用场景是网站维护——定期扫描自己的网站,发现失效的外链和内链。

准备工作

安装好 OpenCode 后,在终端进入项目目录,运行 opencode 即可开始对话。

实践过程

第一步:一句话生成核心代码

我对 OpenCode 说:

> 用 Go 写一个命令行死链检测工具。输入一个网址,提取页面所有链接,逐一检查可访问性,输出检测结果。要求代码约 80 行,只用标准库。

OpenCode 立刻生成了完整的 main.go 文件,包含:

  • flag 解析命令行参数(-url-t 超时)
  • 用正则提取 <a href="..."> 中的链接
  • net/http 发送 HEAD 请求检查每个链接
  • 输出检测结果列表

要点: OpenCode 一次就生成了可编译运行的代码,无需手动写任何一行。关键是描述要清晰——说明输入、处理逻辑、输出格式。

第二步:加并发,Goroutine 一步到位

单线程检测大量链接太慢,我继续对话:

> 改成并发检测,用 goroutine 和 channel 实现 worker pool 模式。加 -c 参数控制并发数,默认 10。

OpenCode 修改了代码:

  • 创建了 urls channel 分发任务、results channel 收集结果
  • 启动 N 个 worker goroutine,每个从 channel 取链接检测
  • 使用 sync.WaitGroup 确保所有 worker 完成后关闭 results channel
  • 添加了 -c 命令行参数

要点: 并发编程通常需要手动写样板代码,OpenCode 直接生成了正确的 worker pool 模式,连 WaitGroup 和 channel 关闭时机都处理对了。

第三步:优化输出,分类展示

裸输出不够友好,我说:

> 输出分成两部分:先列出可用链接,再列出失效链接。失效链接前面显示 HTTP 状态码。

OpenCode 调整了 main 函数:

  • 收集结果后按 status == 200 分类到两个切片
  • 先打印可用链接列表,显示数量
  • 再打印失效链接列表,每行显示 [状态码] URL

要点: OpenCode 理解"分类展示"的意图,自动添加了切片分类、分组输出的逻辑。不需要解释"用 if 判断 200"这种实现细节。

第四步:边界处理

我补了一句:

> 过滤掉空链接、# 锚点、javascript: 伪协议。

OpenCode 在链接提取函数里加了过滤条件:

if link == "" || strings.HasPrefix(link, "#") || strings.HasPrefix(link, "javascript:") {
    continue
}

要点: 这种边界情况人类常忘,但对 OpenCode 随口提一句就能补上。这就是 AI 辅助开发的优势——把"别忘了"变成"加上了"。

完整代码

package main

import (
	"flag"
	"fmt"
	"io"
	"net/http"
	"regexp"
	"strings"
	"sync"
	"time"
)

type result struct {
	url    string
	status int
}

func main() {
	target := flag.String("url", "", "目标网址")
	workers := flag.Int("c", 10, "并发数")
	timeout := flag.Int("t", 5, "超时秒数")
	flag.Parse()

	if *target == "" {
		fmt.Println("用法: deadlink -url https://example.com [-c 10] [-t 5]")
		return
	}

	client := &http.Client{Timeout: time.Duration(*timeout) * time.Second}
	resp, err := client.Get(*target)
	if err != nil {
		fmt.Printf("获取页面失败: %v\n", err)
		return
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	links := extractLinks(string(body))
	fmt.Printf("从 %s 提取到 %d 个链接\n\n", *target, len(links))

	urls := make(chan string, len(links))
	results := make(chan result, len(links))
	var wg sync.WaitGroup

	for i := 0; i < *workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for u := range urls {
				results <- result{u, checkLink(u, client)}
			}
		}()
	}

	for _, link := range links {
		urls <- link
	}
	close(urls)
	wg.Wait()
	close(results)

	var okLinks, brokenLinks []result
	for r := range results {
		if r.status == 200 {
			okLinks = append(okLinks, r)
		} else {
			brokenLinks = append(brokenLinks, r)
		}
	}

	fmt.Printf("✅ 可用链接 (%d):\n", len(okLinks))
	for _, r := range okLinks {
		fmt.Printf("  %s\n", r.url)
	}

	fmt.Printf("\n❌ 失效链接 (%d):\n", len(brokenLinks))
	for _, r := range brokenLinks {
		fmt.Printf("  [%d] %s\n", r.status, r.url)
	}
}

func extractLinks(html string) []string {
	re := regexp.MustCompile(`<a[^>]+href=["']([^"']+)["']`)
	matches := re.FindAllStringSubmatch(html, -1)
	seen := make(map[string]bool)
	var links []string
	for _, m := range matches {
		link := strings.TrimSpace(m[1])
		if link == "" || strings.HasPrefix(link, "#") || strings.HasPrefix(link, "javascript:") {
			continue
		}
		if !seen[link] {
			seen[link] = true
			links = append(links, link)
		}
	}
	return links
}

func checkLink(url string, client *http.Client) int {
	resp, err := client.Head(url)
	if err != nil {
		return 0
	}
	defer resp.Body.Close()
	return resp.StatusCode
}

小结

从写出第一行到跑通测试,这篇文章中的每一步改动都只用了一句话描述需求,不用写一行 Go 代码。四轮对话加起来不到 4 分钟。

对比传统手写方式:写并发 worker pool 需要查文档、调 channel 关闭时机、处理 WaitGroup 计数,光这一步就得花不少时间。OpenCode 把"写代码"变成了"描述需求"——你负责定义 What,OpenCode 负责实现 How。