Files
sese-engine-go/parser/parser.go
T
2026-04-09 17:03:10 +08:00

265 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package parser extracts title, description, text content, and links from HTML.
// parser 包负责 HTML 解析:从网页 HTML 中提取标题、描述、正文和所有超链接。
package parser
import (
"net/url" // URL 解析(规范化)
"path" // 路径处理(提取目录、规范化相对路径)
"regexp" // 正则表达式(空白字符替换)
"sort" // query 参数排序(URL 规范化去重)
"strings" // 字符串操作
"golang.org/x/net/html" // 标准 HTML 解析器(将 HTML 解析为 DOM 树)
)
// wsRe 空白字符正则:将任意连续空白字符(空格、换行、制表符等)替换为单个空格。
var wsRe = regexp.MustCompile(`\s+`)
// ParseHTML 解析 HTML 文档,返回标题、meta 描述、正文文本和所有超链接列表。
// body:原始 HTML 字符串;baseURL:用于解析相对链接的基准 URL。
func ParseHTML(body, baseURL string) (title, description, text string, hrefs []string) {
// 从 baseURL 提取基准协议和主机(如 "https://example.com"
base := baseFromURL(baseURL)
// 从 baseURL 提取当前页面路径(如 "/path/page.html"
basePath := pathFromURL(baseURL)
// 将 HTML 字符串解析为 DOM 树
doc, err := html.Parse(strings.NewReader(body))
if err != nil {
return // 解析失败返回空
}
var textParts []string // 收集所有正文文本片段
// 深度优先遍历 DOM 树
var dfs func(n *html.Node)
dfs = func(n *html.Node) {
if n.Type == html.ElementNode {
tag := strings.ToLower(n.Data)
// 跳过 <script>、<style>、<svg> 等无需解析内容的标签
if tag == "script" || tag == "style" || tag == "svg" {
return
}
// 提取 <meta name="description" content="..."> 标签
if tag == "meta" {
name := ""
content := ""
for _, a := range n.Attr {
switch strings.ToLower(a.Key) {
case "name":
name = strings.ToLower(a.Val)
case "content":
content = a.Val
}
}
// 只取第一个描述
if name == "description" && description == "" {
description = content
}
}
// 提取 <a href="..."> 链接
if tag == "a" {
href := attrVal(n, "href")
if href != "" && isSafeURL(href) {
href = strings.SplitN(href, "#", 2)[0]
if href != "" {
href = resolveURL(base, basePath, href)
if href != "" {
href = NormalizeURL(href)
if href != "" {
hrefs = append(hrefs, href)
}
}
}
}
}
}
// 提取文本节点(<title> 和正文内容)
if n.Type == html.TextNode && n.Parent != nil {
parentTag := ""
if n.Parent.Type == html.ElementNode {
parentTag = strings.ToLower(n.Parent.Data)
}
// 跳过 script/style/svg 内的文本
if parentTag == "script" || parentTag == "style" || parentTag == "svg" {
goto children
}
// 空白压缩并去除首尾空格
s := wsRe.ReplaceAllString(n.Data, " ")
s = strings.TrimSpace(s)
if s != "" {
if parentTag == "title" {
title = s // 标题只取第一个
} else {
textParts = append(textParts, s) // 正文片段收集
}
}
}
children:
// 递归遍历子节点
for c := n.FirstChild; c != nil; c = c.NextSibling {
dfs(c)
}
}
dfs(doc)
// 将正文片段用空格连接为完整文本
text = strings.Join(textParts, " ")
return
}
// attrVal 提取 HTML 节点上指定名称的属性值(不区分大小写)。
func attrVal(n *html.Node, key string) string {
for _, a := range n.Attr {
if strings.ToLower(a.Key) == key {
return a.Val
}
}
return ""
}
// baseFromURL 从原始 URL 提取 "scheme://host" 部分(不含路径)。
// 例如:"https://example.com/path/page" → "https://example.com"。
func baseFromURL(rawURL string) string {
idx := strings.Index(rawURL, "://")
if idx < 0 {
return ""
}
rest := rawURL[idx+3:]
slash := strings.Index(rest, "/")
if slash < 0 {
return rawURL // 无路径,直接返回整个 URL
}
return rawURL[:idx+3+slash]
}
// pathFromURL 从原始 URL 提取路径部分(不含域名)。
// 例如:"https://example.com/path/page?q=1#top" → "/path/page"。
func pathFromURL(rawURL string) string {
idx := strings.Index(rawURL, "://")
if idx < 0 {
return "/"
}
rest := rawURL[idx+3:]
slash := strings.Index(rest, "/")
if slash < 0 {
return "/"
}
p := rest[slash:] // 从第一个斜杠开始即为路径
// 去除查询字符串和锚点
p = strings.SplitN(p, "?", 2)[0]
p = strings.SplitN(p, "#", 2)[0]
return p
}
// resolveURL 将相对 href 解析为绝对 URL,参考 base(协议+主机)和 basePath(当前页面路径)。
// 支持:http://、https:// 绝对 URL// 协议相对 URL;/ 绝对路径;相对路径。
func resolveURL(base, basePath, href string) string {
// 已经是绝对 URL,直接返回
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
return href
}
// 协议相对 URL(以 // 开头),补上协议
if strings.HasPrefix(href, "//") {
idx := strings.Index(base, "://")
if idx < 0 {
return ""
}
return base[:idx+1] + href
}
// 绝对路径(以 / 开头),拼接域名
if strings.HasPrefix(href, "/") {
return base + href
}
// 相对路径:基于当前页面目录拼接
dir := path.Dir(basePath) // 提取当前页面的目录部分
return base + path.Clean(dir+"/"+href) // path.Clean 规范化,去除多余的 ../ 等
}
// isSafeURL 检查 href 是否为安全的 HTTP(S) 链接。
// 过滤 javascript:、data:、mailto:、tel:、ftp: 等伪协议,
// 以及空 href 和仅包含锚点的 href。
func isSafeURL(href string) bool {
if href == "" || href == "#" {
return false
}
// 检查是否包含冒号(伪协议特征)
colon := strings.Index(href, ":")
if colon < 0 {
return true // 无冒号:相对路径、绝对路径、协议相对路径,都是安全的
}
scheme := strings.ToLower(href[:colon])
switch scheme {
case "http", "https":
return true
default:
// javascript:, data:, mailto:, tel:, ftp:, vbscript: 等全部拦截
return false
}
}
// NormalizeURL 将 URL 规范化为用于去重的标准形式。
// 1. 统一为小写 scheme 和 host
// 2. path.Clean 规范化路径(去除 ./、../)
// 3. 按 key 字典序排列 query 参数(消除 ?a=1&b=2 vs ?b=2&a=1 的差异)
// 4. 去除 fragment
// 5. 去除末尾斜杠(根路径 / 除外)
// 返回空字符串表示 URL 无效。
func NormalizeURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
if u.Scheme != "http" && u.Scheme != "https" {
return ""
}
if u.Host == "" {
return ""
}
// 统一 scheme 和 host 为小写
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
// 规范化路径
if u.Path == "" {
u.Path = "/"
}
u.Path = path.Clean(u.Path)
// query 参数按 key 字典序排列
if u.RawQuery != "" {
u.RawQuery = sortQuery(u.RawQuery)
}
// 去除 fragment
u.Fragment = ""
// 去除末尾斜杠(根路径 / 除外)
if len(u.Path) > 1 && strings.HasSuffix(u.Path, "/") {
u.Path = strings.TrimRight(u.Path, "/")
}
return u.String()
}
// sortQuery 将 query 字符串的参数按 key 字典序排列,用于 URL 去重。
func sortQuery(query string) string {
params, err := url.ParseQuery(query)
if err != nil {
return query
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
// url.Values 编码后参数已排序且值已去重
return params.Encode()
}