265 lines
7.5 KiB
Go
265 lines
7.5 KiB
Go
// 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()
|
||
}
|