Skip to content

错误处理

错误分类

HTML 库的错误分为以下几类:

类别哨兵错误说明
输入错误ErrInputTooLarge, ErrInvalidHTML输入内容问题
配置错误ErrInvalidConfig, ErrMultipleConfigs配置问题
文件错误ErrFileNotFound, ErrInvalidFilePath文件操作问题
处理错误ErrProcessingTimeout, ErrMaxDepthExceeded处理过程问题
系统错误ErrProcessorClosed, ErrInternalPanic内部状态问题

errors.Is 模式

使用 errors.Is 判断错误类型:

go
result, err := html.Extract(data)
if err != nil {
    switch {
    case errors.Is(err, html.ErrInputTooLarge):
        slog.Warn("输入过大,请减小文档大小")
    case errors.Is(err, html.ErrInvalidHTML):
        slog.Warn("无效 HTML,请检查输入")
    case errors.Is(err, html.ErrProcessingTimeout):
        slog.Warn("处理超时,文档可能过于复杂")
    case errors.Is(err, html.ErrFileNotFound):
        slog.Warn("文件不存在")
    case errors.Is(err, html.ErrMaxDepthExceeded):
        slog.Warn("DOM 深度过深,可能是恶意构造")
    case errors.Is(err, html.ErrInternalPanic):
        slog.Error("内部恐慌恢复,请报告此问题")
    default:
        slog.Error("未知错误", "err", err)
    }
}

errors.As 模式

提取结构化错误信息:

go
var inputErr *html.InputError
var configErr *html.ConfigError
var fileErr *html.FileError

if errors.As(err, &inputErr) {
    fmt.Printf("大小 %d 超过限制 %d\n", inputErr.Size, inputErr.MaxSize)
}

if errors.As(err, &configErr) {
    fmt.Printf("字段 %s%v 无效: %s\n", configErr.Field, configErr.Value, configErr.Message)
}

if errors.As(err, &fileErr) {
    fmt.Printf("文件操作: %s\n", fileErr.SafePath())
}

上下文取消

使用 ExtractWithContext 版本支持取消:

go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

result, err := html.ExtractWithContext(ctx, data)
if err != nil {
    switch {
    case errors.Is(err, html.ErrProcessingTimeout):
        // 处理超时(库内 ProcessingTimeout 触发,此时 ctx.Err() 可能为 nil)
    case ctx.Err() == context.DeadlineExceeded:
        // 用户上下文截止时间到
    case ctx.Err() == context.Canceled:
        // 手动取消
    default:
        // 其他错误(ErrInvalidHTML、ErrInputTooLarge 等)
        slog.Error("提取失败", "err", err)
    }
}

批量错误

批量处理的结果包含部分成功和部分失败:

go
batch := p.ExtractBatch(pages)

for i, err := range batch.Errors {
    if err != nil {
        fmt.Printf("第 %d 项失败: %v\n", i, err)
    }
}

fmt.Printf("成功:%d, 失败:%d, 取消:%d\n",
    batch.Success, batch.Failed, batch.Cancelled)

错误恢复策略

实际应用中,仅判断错误类型还不够——需要根据错误类别采取不同的恢复策略。

编码检测失败

当 HTML 输入不含 <meta charset> 声明且自动检测无法确定编码时,库返回的错误消息以 "encoding detection failed" 为前缀。这是一个普通的 fmt.Errorf 包装错误(非哨兵错误、非类型化错误),只能通过错误消息字符串匹配来检测。

恢复策略:先用自动检测(Config.Encoding 留空),失败后用已知编码手动指定重试。

go
package main

import (
	"fmt"
	"log"
	"strings"

	"github.com/cybergodev/html"
	"golang.org/x/text/encoding/simplifiedchinese"
)

// extractWithEncodingFallback 先用自动检测尝试提取,编码检测失败时用手动编码重试。
func extractWithEncodingFallback(data []byte, fallbackEncoding string) (*html.Result, error) {
	// 第一次:自动检测编码(Config.Encoding 留空)
	result, err := html.Extract(data)
	if err == nil {
		return result, nil
	}

	// 编码检测失败时,用手动指定的编码重试
	if strings.Contains(err.Error(), "encoding detection failed") {
		fmt.Printf("自动检测失败(%v),使用编码 %q 重试...\n", err, fallbackEncoding)
		cfg := html.DefaultConfig()
		cfg.Encoding = fallbackEncoding
		return html.Extract(data, cfg)
	}

	// 其他错误(输入过大、无效 HTML 等)不重试,直接返回
	return nil, err
}

func main() {
	// 构造 GBK 编码的 HTML(无 charset meta 声明,可能触发自动检测失败)
	utf8HTML := `<html><head><title>测试</title></head>` +
		`<body><article><h1>标题</h1><p>你好世界</p></article></body></html>`
	gbkBytes, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(utf8HTML))
	if err != nil {
		log.Fatal(err)
	}

	result, err := extractWithEncodingFallback(gbkBytes, "gbk")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("标题: %s\n", result.Title)
	fmt.Printf("文本: %s\n", result.Text)
	// 输出:
	// 自动检测失败(encoding detection failed: ...),使用编码 "gbk" 重试...
	// 标题: 测试
	// 文本: 标题 你世界
}

提示

编码检测重试适用于处理来源不明的 HTML 文档(如爬虫抓取的旧版中文网页)。如果你的输入来源固定,直接在 Config.Encoding 中指定编码即可,无需重试逻辑。

超时恢复

ErrProcessingTimeout 表示处理时间超过了 Config.ProcessingTimeout。恢复策略取决于文档特性:

策略适用场景做法
降低复杂度文档结构复杂但内容简单设置 ExtractArticle = false 跳过正文识别
延长超时文档确实很大且合法增大 ProcessingTimeout
简化输出仅需纯文本使用 TextOnlyConfig() 禁用所有媒体提取
go
package main

import (
	"errors"
	"fmt"
	"log"
	"strings"
	"time"

	"github.com/cybergodev/html"
)

func main() {
	// 第一次:标准配置(30 秒超时)
	cfg := html.DefaultConfig()
	p, err := html.New(cfg)
	if err != nil {
		log.Fatal(err)
	}
	defer p.Close()

	largeHTML := []byte(`<html><body><article><h1>大文档</h1><p>` +
		strings.Repeat("内容 ", 100000) + `</p></article></body></html>`)

	_, err = p.Extract(largeHTML)
	if err != nil {
		if errors.Is(err, html.ErrProcessingTimeout) {
			fmt.Println("标准配置超时,切换到简化模式重试...")

			// 重试:关闭正文提取 + 纯文本模式 + 更长超时
			retryCfg := html.TextOnlyConfig()
			retryCfg.ExtractArticle = false
			retryCfg.ProcessingTimeout = 60 * time.Second
			p2, err := html.New(retryCfg)
			if err != nil {
				log.Fatal(err)
			}
			defer p2.Close()

			result, err := p2.Extract(largeHTML)
			if err != nil {
				log.Fatal(err)
			}
			fmt.Printf("重试成功,提取 %d\n", len(result.Text))
		} else {
			log.Fatal(err)
		}
	}
}

输入过大

ErrInputTooLarge 表示输入超过了 Config.MaxInputSize(默认 50MB,上限也是 50MB)。两种处理方式:

  • 缩减输入:如果是 Web 服务,提示用户上传更小的文件
  • 增大限制:如果业务确实需要处理大文件,增大 MaxInputSize(上限 50MB)
go
package main

import (
	"errors"
	"fmt"
	"log"
	"strings"

	"github.com/cybergodev/html"
)

func main() {
	cfg := html.DefaultConfig()
	cfg.MaxInputSize = 1024 // 1KB 限制(演示用)
	p, err := html.New(cfg)
	if err != nil {
		log.Fatal(err)
	}
	defer p.Close()

	// 构造超过限制的输入
	largeInput := []byte(strings.Repeat("<div>", 500))
	_, err = p.Extract(largeInput)
	if err != nil {
		var inputErr *html.InputError
		if errors.As(err, &inputErr) {
			fmt.Printf("输入 %d 字节超过限制 %d 字节\n", inputErr.Size, inputErr.MaxSize)
			// 输出:输入 2500 字节超过限制 1024 字节
		}
	}
}

错误包装链

三种结构化错误类型(InputErrorConfigErrorFileError)都实现了 Unwrap() 方法,支持 errors.Is()errors.As() 标准模式。理解 Unwrap() 的行为对于正确判断错误至关重要。

Unwrap 行为对照

类型Unwrap() 返回值说明
*InputErrorInputErr(非 nil 时)→ 否则 ErrInputTooLarge有底层错误时优先暴露它;否则回退到哨兵
*ConfigError始终 ErrInvalidConfig固定映射到配置哨兵
*FileError① 若 FileErr 包装了 ErrFileNotFoundErrFileNotFound;② 否则若 FileErr != nilFileErr(原始错误);③ 否则 → ErrInvalidFilePath三级回退:文件不存在 → 原始错误 → 路径无效

注意

FileError.Unwrap() 的三级逻辑意味着:路径遍历攻击产生的错误(FileErr = "path traversal detected: ...")不会匹配任何哨兵错误——因为 Unwrap() 返回的是原始的路径遍历错误,而非 ErrFileNotFoundErrInvalidFilePath。检测路径遍历需要用 errors.As 提取 FileError 后检查消息。

综合判断示例

go
package main

import (
	"errors"
	"fmt"
	"log"
	"strings"

	"github.com/cybergodev/html"
)

// diagnoseError 用 errors.Is + errors.As 综合诊断错误。
func diagnoseError(err error) {
	if err == nil {
		fmt.Println("无错误")
		return
	}

	// 1. errors.Is:检查哨兵错误(沿 Unwrap 链查找)
	fmt.Printf("errors.Is 检查:\n")
	fmt.Printf("  ErrInputTooLarge:    %v\n", errors.Is(err, html.ErrInputTooLarge))
	fmt.Printf("  ErrInvalidConfig:    %v\n", errors.Is(err, html.ErrInvalidConfig))
	fmt.Printf("  ErrFileNotFound:     %v\n", errors.Is(err, html.ErrFileNotFound))
	fmt.Printf("  ErrInvalidFilePath:  %v\n", errors.Is(err, html.ErrInvalidFilePath))

	// 2. errors.As:提取结构化错误类型
	var inputErr *html.InputError
	if errors.As(err, &inputErr) {
		fmt.Printf("InputError 详情: op=%s size=%d max=%d\n",
			inputErr.Op, inputErr.Size, inputErr.MaxSize)
	}

	var configErr *html.ConfigError
	if errors.As(err, &configErr) {
		fmt.Printf("ConfigError 详情: field=%s value=%v message=%s\n",
			configErr.Field, configErr.Value, configErr.Message)
	}

	var fileErr *html.FileError
	if errors.As(err, &fileErr) {
		fmt.Printf("FileError 详情: op=%s safePath=%s\n",
			fileErr.Op, fileErr.SafePath())
		// 检测路径遍历(不匹配哨兵,需检查消息)
		if fileErr.FileErr != nil &&
			strings.Contains(fileErr.FileErr.Error(), "path traversal") {
			fmt.Println("  [安全警告] 检测到路径遍历攻击")
		}
	}
}

func main() {
	cfg := html.DefaultConfig()
	cfg.MaxInputSize = 100
	p, err := html.New(cfg)
	if err != nil {
		log.Fatal(err)
	}
	defer p.Close()

	// 场景 1:输入过大 → InputError → ErrInputTooLarge
	fmt.Println("=== 场景 1:输入过大 ===")
	_, err = p.Extract([]byte(strings.Repeat("x", 200)))
	diagnoseError(err)

	// 场景 2:文件不存在 → FileError → ErrFileNotFound
	fmt.Println("\n=== 场景 2:文件不存在 ===")
	_, err = p.ExtractFromFile("nonexistent.html")
	diagnoseError(err)

	// 场景 3:路径遍历 → FileError → 不匹配哨兵
	fmt.Println("\n=== 场景 3:路径遍历 ===")
	_, err = p.ExtractFromFile("../../etc/passwd")
	diagnoseError(err)
}

FileError 的路径脱敏机制

FileError 在设计上内置了路径信息脱敏机制,防止通过错误消息泄露服务器文件系统结构。这在处理来自不可信用户的文件路径时尤为重要。

脱敏层次

层次方法行为
错误消息Error()调用 SafePath() 仅显示文件名,调用 sanitizeErrorMessage() 移除路径细节
路径截断SafePath()返回路径的 basename(如 /var/data/secret/page.htmlpage.html
错误清洗sanitizeErrorMessage()保留错误类型(path traversal / not found / permission denied / access denied),移除路径字符串
JSON 序列化MarshalJSON()自动用 SafePath() 脱敏,适用于 HTTP API 响应
内部调试Path 字段保留完整路径,供日志和审计使用(不对外暴露)

Web 服务脱敏示例

go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"

	"github.com/cybergodev/html"
)

// apiResponse 是返回给客户端的 JSON 结构。
type apiResponse struct {
	Error   string `json:"error,omitempty"`
	Message string `json:"message,omitempty"`
}

func extractHandler(w http.ResponseWriter, r *http.Request) {
	filePath := r.URL.Query().Get("file")
	if filePath == "" {
		w.WriteHeader(http.StatusBadRequest)
		json.NewEncoder(w).Encode(apiResponse{Error: "missing file parameter"})
		return
	}

	cfg := html.DefaultConfig()
	result, err := html.ExtractFromFile(filePath, cfg)
	if err != nil {
		// 错误消息已自动脱敏——客户端不会看到服务器路径
		w.WriteHeader(http.StatusUnprocessableEntity)

		var fileErr *html.FileError
		if errors.As(err, &fileErr) {
			// MarshalJSON 自动用 SafePath() 脱敏,可安全返回客户端
			fileErrJSON, _ := json.Marshal(fileErr)
			fmt.Fprintf(w, `{"error":"file_error","detail":%s}`, fileErrJSON)
			// 客户端看到: {"error":"file_error","detail":{"op":"ReadFile","path":"secret.html","message":"file not found"}}
			// 而非完整的服务器路径 /var/www/uploads/secret.html
		} else {
			json.NewEncoder(w).Encode(apiResponse{Error: "extraction_failed"})
		}
		return
	}

	json.NewEncoder(w).Encode(result)
}

func main() {
	// 演示脱敏效果:模拟处理一个不存在的文件路径
	cfg := html.DefaultConfig()
	_, err := html.ExtractFromFile("/var/www/private/secret.html", cfg)
	if err != nil {
		var fileErr *html.FileError
		if errors.As(err, &fileErr) {
			fmt.Printf("Error() 输出(脱敏): %v\n", fileErr)
			// 输出:Error() 输出(脱敏): html: ReadFile "secret.html": file not found

			fmt.Printf("SafePath(): %s\n", fileErr.SafePath())
			// 输出:SafePath(): secret.html

			jsonBytes, _ := json.Marshal(fileErr)
			fmt.Printf("MarshalJSON(): %s\n", jsonBytes)
			// 输出:MarshalJSON(): {"op":"ReadFile","path":"secret.html","message":"file not found"}

			fmt.Printf("Path 字段(内部调试用): %s\n", fileErr.Path)
			// 输出:Path 字段(内部调试用): /var/www/private/secret.html
		}
	}

	// 注册 HTTP 处理器(仅注册,不启动服务器)
	http.HandleFunc("/extract", extractHandler)
	fmt.Println("\n处理器已注册,错误消息自动脱敏")
}

提示

MarshalJSON() 使得 FileError 可以直接 json.Marshal() 后返回给 HTTP 客户端,无需额外处理——路径信息在序列化时已自动脱敏。但 Path 字段仍保留完整路径,仅用于内部日志和调试,切勿将其直接返回给客户端。

Web 服务错误映射

在 Web 服务中,需要将库的错误映射为合适的 HTTP 状态码,让客户端能正确处理。

HTTP 状态码映射表

哨兵错误建议 HTTP 状态码说明
ErrInputTooLarge413 Payload Too Large输入超限,客户端应缩减输入
ErrInvalidHTML422 Unprocessable Entity无法解析的 HTML 格式
ErrFileNotFound404 Not Found文件不存在
ErrInvalidFilePath400 Bad Request路径格式无效
ErrMaxDepthExceeded400 Bad Request可能恶意构造的深度嵌套
ErrProcessingTimeout504 Gateway Timeout处理超时,客户端可稍后重试
ErrProcessorClosed500 Internal Server Error编程错误(未正确管理生命周期)
ErrInvalidConfig500 Internal Server Error编程错误(配置校验应在启动时完成)
ErrInternalPanic500 Internal Server Error内部 bug,应上报
ErrMultipleConfigs500 Internal Server Error编程错误(传入了多个 Config)

状态码映射实现

go
package main

import (
	"errors"
	"fmt"
	"log"
	"net/http"

	"github.com/cybergodev/html"
)

// errorToHTTPStatus 将 html 库的错误映射为合适的 HTTP 状态码。
func errorToHTTPStatus(err error) int {
	switch {
	case errors.Is(err, html.ErrInputTooLarge):
		return http.StatusRequestEntityTooLarge // 413
	case errors.Is(err, html.ErrInvalidHTML):
		return http.StatusUnprocessableEntity // 422
	case errors.Is(err, html.ErrFileNotFound):
		return http.StatusNotFound // 404
	case errors.Is(err, html.ErrInvalidFilePath):
		return http.StatusBadRequest // 400
	case errors.Is(err, html.ErrMaxDepthExceeded):
		return http.StatusBadRequest // 400
	case errors.Is(err, html.ErrProcessingTimeout):
		return http.StatusGatewayTimeout // 504
	case errors.Is(err, html.ErrProcessorClosed):
		return http.StatusInternalServerError // 500
	case errors.Is(err, html.ErrInvalidConfig):
		return http.StatusInternalServerError // 500
	case errors.Is(err, html.ErrInternalPanic):
		return http.StatusInternalServerError // 500
	case errors.Is(err, html.ErrMultipleConfigs):
		return http.StatusInternalServerError // 500
	default:
		return http.StatusInternalServerError // 500
	}
}

func main() {
	// 演示各种错误的 HTTP 状态码映射

	// ErrInputTooLarge → 413
	cfg := html.DefaultConfig()
	cfg.MaxInputSize = 10
	p, err := html.New(cfg)
	if err != nil {
		log.Fatal(err)
	}

	testCases := []struct {
		name string
		err  error
	}{
		{"输入过大", func() error {
			_, e := p.Extract(make([]byte, 100))
			return e
		}()},
		{"文件不存在", func() error {
			_, e := p.ExtractFromFile("missing.html")
			return e
		}()},
		{"处理器已关闭", html.ErrProcessorClosed},
		{"内部恐慌", html.ErrInternalPanic},
	}

	for _, tc := range testCases {
		if tc.err != nil {
			fmt.Printf("%-12s → HTTP %d\n", tc.name, errorToHTTPStatus(tc.err))
		}
	}
	// 输出:
	// 输入过大      → HTTP 413
	// 文件不存在    → HTTP 404
	// 处理器已关闭  → HTTP 500
	// 内部恐慌      → HTTP 500

	p.Close()
}

错误决策流程图

遇到错误时,按以下优先级顺序判断(从最严重到最轻微):

error != nil ?

├── errors.Is(err, ErrProcessorClosed)
│   → 编程错误:检查 Close() 调用时机,确认处理器未在使用后被关闭

├── errors.Is(err, ErrInternalPanic)
│   → 内部 bug:记录完整堆栈并上报,输入可能触发了未覆盖的边界情况

├── errors.As(err, &fileErr)
│   → 文件错误:用 SafePath() 记录脱敏路径,检查 FileErr 判断具体原因
│   ├── errors.Is(err, ErrFileNotFound)    → 文件不存在
│   ├── 消息含 "path traversal"              → 安全事件,审计日志 + 拒绝
│   └── errors.Is(err, ErrInvalidFilePath) → 路径格式问题

├── errors.As(err, &inputErr)
│   → 输入错误:检查 Size/MaxSize,提示用户缩减输入或调整限制

├── errors.Is(err, ErrProcessingTimeout)
│   → 超时:考虑简化处理(ExtractArticle=false)或增大超时后重试

├── errors.Is(err, ErrMaxDepthExceeded)
│   → 可能恶意构造:拒绝并记录审计日志

├── errors.Is(err, ErrInvalidHTML)
│   → 输入格式问题:提示用户检查 HTML 源

├── errors.Is(err, ErrInvalidConfig)
│   → 配置错误:应在服务启动时 Validate() 捕获,运行时出现说明逻辑有误

└── 其他
    → 未知错误:记录完整错误链,向上传播或返回 500

提示

判断顺序很重要:ErrProcessorClosedErrInternalPanic 应优先检查,因为它们代表编程错误或内部故障,需要与输入错误区别处理。FileErrorerrors.As 检查应在 errors.Is 哨兵检查之后或并用——因为路径遍历错误不匹配任何哨兵。

结构化日志实践

使用 slog 记录错误时,应从结构化错误类型中提取字段(而非仅记录 err.Error() 字符串),以便后续日志查询和告警。

go
package main

import (
	"errors"
	"fmt"
	"log/slog"
	"strings"

	"github.com/cybergodev/html"
)

// logExtractionError 根据错误类型提取结构化字段,记录到 slog。
func logExtractionError(err error) {
	var inputErr *html.InputError
	var configErr *html.ConfigError
	var fileErr *html.FileError

	switch {
	case errors.As(err, &inputErr):
		// 输入错误:记录 Op/Size/MaxSize 便于排查容量问题
		slog.Warn("提取失败:输入错误",
			"op", inputErr.Op,
			"size", inputErr.Size,
			"max_size", inputErr.MaxSize,
			"sentinel", "ErrInputTooLarge",
		)

	case errors.As(err, &configErr):
		// 配置错误:记录 Field/Value/Message 便于定位配置问题
		slog.Error("提取失败:配置错误",
			"field", configErr.Field,
			"value", configErr.Value,
			"message", configErr.Message,
			"sentinel", "ErrInvalidConfig",
		)

	case errors.As(err, &fileErr):
		// 文件错误:用 SafePath() 记录脱敏路径,检查路径遍历
		attrs := []any{
			"op", fileErr.Op,
			"path", fileErr.SafePath(), // 脱敏路径,避免日志泄露完整路径
		}
		if fileErr.FileErr != nil {
			attrs = append(attrs, "cause", fileErr.FileErr.Error())
			if strings.Contains(fileErr.FileErr.Error(), "path traversal") {
				attrs = append(attrs, "security_event", "path_traversal")
			}
		}
		slog.Warn("提取失败:文件错误", attrs...)

	case errors.Is(err, html.ErrProcessingTimeout):
		slog.Warn("提取失败:处理超时", "err", err)

	case errors.Is(err, html.ErrMaxDepthExceeded):
		slog.Warn("提取失败:深度超限,可能恶意构造", "err", err)

	case errors.Is(err, html.ErrProcessorClosed):
		slog.Error("提取失败:处理器已关闭(编程错误)", "err", err)

	case errors.Is(err, html.ErrInternalPanic):
		slog.Error("提取失败:内部恐慌,请上报",
			"err", err,
			"issue", "https://github.com/cybergodev/html/issues",
		)

	default:
		slog.Error("提取失败:未知错误", "err", err, "err_type", fmt.Sprintf("%T", err))
	}
}

func main() {
	cfg := html.DefaultConfig()
	cfg.MaxInputSize = 100
	p, err := html.New(cfg)
	if err != nil {
		slog.Error("创建处理器失败", "err", err)
		return
	}
	defer p.Close()

	// 场景 1:输入过大 → 结构化记录 Size/MaxSize
	_, err = p.Extract([]byte(strings.Repeat("x", 200)))
	if err != nil {
		logExtractionError(err)
	}

	// 场景 2:文件不存在 → 结构化记录 SafePath
	_, err = p.ExtractFromFile("/data/secret/missing.html")
	if err != nil {
		logExtractionError(err)
	}

	// 场景 3:路径遍历 → 标记安全事件
	_, err = p.ExtractFromFile("../../../etc/passwd")
	if err != nil {
		logExtractionError(err)
	}
}

提示

结构化日志的关键是提取字段而非拼接字符串。例如记录 inputErr.SizeinputErr.MaxSize 后,可以在日志系统中按 size > max_size * 0.9 查询接近上限的请求,提前发现容量问题。对 FileError,始终用 SafePath() 而非 Path 字段记录日志,避免日志文件本身成为信息泄露源。