加上中文注释
This commit is contained in:
+46
-20
@@ -1,36 +1,46 @@
|
||||
// Package parser extracts title, description, text content, and links from HTML.
|
||||
// parser 包负责 HTML 解析:从网页 HTML 中提取标题、描述、正文和所有超链接。
|
||||
package parser
|
||||
|
||||
import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"path" // 路径处理(提取目录、规范化相对路径)
|
||||
"regexp" // 正则表达式(空白字符替换)
|
||||
"strings" // 字符串操作
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html" // 标准 HTML 解析器(将 HTML 解析为 DOM 树)
|
||||
)
|
||||
|
||||
// wsRe 空白字符正则:将任意连续空白字符(空格、换行、制表符等)替换为单个空格。
|
||||
var wsRe = regexp.MustCompile(`\s+`)
|
||||
|
||||
// ParseHTML parses an HTML document and returns title, meta description, body text, and href list.
|
||||
// ParseHTML 解析 HTML 文档,返回标题、meta 描述、正文文本和所有超链接列表。
|
||||
// body:原始 HTML 字符串;baseURL:用于解析相对链接的基准 URL。
|
||||
func ParseHTML(body, baseURL string) (title, description, text string, hrefs []string) {
|
||||
// Determine base scheme+host
|
||||
// 从 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
|
||||
return // 解析失败返回空
|
||||
}
|
||||
|
||||
var textParts []string
|
||||
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 := ""
|
||||
@@ -42,15 +52,20 @@ func ParseHTML(body, baseURL string) (title, description, text string, hrefs []s
|
||||
content = a.Val
|
||||
}
|
||||
}
|
||||
// 只取第一个描述
|
||||
if name == "description" && description == "" {
|
||||
description = content
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 <a href="..."> 链接
|
||||
if tag == "a" {
|
||||
href := attrVal(n, "href")
|
||||
if href != "" {
|
||||
// 去除 URL 中的锚点(#fragment)
|
||||
href = strings.SplitN(href, "#", 2)[0]
|
||||
if href != "" {
|
||||
// 解析为绝对 URL(处理相对路径、协议相对路径等)
|
||||
href = resolveURL(base, basePath, href)
|
||||
if href != "" {
|
||||
hrefs = append(hrefs, href)
|
||||
@@ -60,36 +75,42 @@ func ParseHTML(body, baseURL string) (title, description, text string, hrefs []s
|
||||
}
|
||||
}
|
||||
|
||||
// 提取文本节点(<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
|
||||
title = s // 标题只取第一个
|
||||
} else {
|
||||
textParts = append(textParts, s)
|
||||
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 {
|
||||
@@ -99,6 +120,8 @@ func attrVal(n *html.Node, key string) string {
|
||||
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 {
|
||||
@@ -107,11 +130,13 @@ func baseFromURL(rawURL string) string {
|
||||
rest := rawURL[idx+3:]
|
||||
slash := strings.Index(rest, "/")
|
||||
if slash < 0 {
|
||||
return rawURL
|
||||
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 {
|
||||
@@ -122,32 +147,33 @@ func pathFromURL(rawURL string) string {
|
||||
if slash < 0 {
|
||||
return "/"
|
||||
}
|
||||
p := rest[slash:]
|
||||
// strip query/fragment
|
||||
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 {
|
||||
// Absolute URL
|
||||
// 已经是绝对 URL,直接返回
|
||||
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
|
||||
return href
|
||||
}
|
||||
// Protocol-relative
|
||||
// 协议相对 URL(以 // 开头),补上协议
|
||||
if strings.HasPrefix(href, "//") {
|
||||
// extract scheme from base
|
||||
idx := strings.Index(base, "://")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return base[:idx+1] + href
|
||||
}
|
||||
// Absolute path
|
||||
// 绝对路径(以 / 开头),拼接域名
|
||||
if strings.HasPrefix(href, "/") {
|
||||
return base + href
|
||||
}
|
||||
// Relative path
|
||||
dir := path.Dir(basePath)
|
||||
return base + path.Clean(dir+"/"+href)
|
||||
// 相对路径:基于当前页面目录拼接
|
||||
dir := path.Dir(basePath) // 提取当前页面的目录部分
|
||||
return base + path.Clean(dir+"/"+href) // path.Clean 规范化,去除多余的 ../ 等
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user