Files
libnovel/backend/internal/novelfire/htmlutil/htmlutil.go
Admin 5825b859b7
All checks were successful
Release / Scraper / Test (push) Successful in 10s
Release / UI / Build (push) Successful in 26s
Release / v2 / Build ui-v2 (push) Successful in 17s
Release / Scraper / Docker (push) Successful in 47s
Release / UI / Docker (push) Successful in 56s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / Scraper / Test (pull_request) Successful in 8s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / v2 / Docker / ui-v2 (push) Successful in 56s
Release / v2 / Test backend (push) Successful in 4m35s
iOS CI / Build (pull_request) Successful in 4m28s
Release / v2 / Docker / backend (push) Successful in 1m29s
Release / v2 / Docker / runner (push) Successful in 1m39s
iOS CI / Test (pull_request) Successful in 9m51s
feat: add v2 stack (backend, runner, ui-v2) with release workflow
- backend/: Go API server and runner binaries with PocketBase + MinIO storage
- ui-v2/: SvelteKit frontend rewrite
- docker-compose-new.yml: compose file for the v2 stack
- .gitea/workflows/release-v2.yaml: CI/CD for backend, runner, and ui-v2 Docker Hub images
- scripts/pb-init.sh: migrate from wget to curl, add superuser bootstrap for fresh installs
- .env.example: document DOCKER_BUILDKIT=1 for Colima users
2026-03-15 19:32:40 +05:00

229 lines
5.4 KiB
Go

// Package htmlutil provides helper functions for parsing HTML with
// golang.org/x/net/html and extracting values by Selector descriptors.
package htmlutil
import (
"net/url"
"regexp"
"strings"
"github.com/libnovel/backend/internal/scraper"
"golang.org/x/net/html"
)
// ResolveURL returns an absolute URL. If href is already absolute it is
// returned unchanged. Otherwise it is resolved against base.
func ResolveURL(base, href string) string {
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
return href
}
b, err := url.Parse(base)
if err != nil {
return base + href
}
ref, err := url.Parse(href)
if err != nil {
return base + href
}
return b.ResolveReference(ref).String()
}
// ParseHTML parses raw HTML and returns the root node.
func ParseHTML(raw string) (*html.Node, error) {
return html.Parse(strings.NewReader(raw))
}
// selectorMatches reports whether node n matches sel.
func selectorMatches(n *html.Node, sel scraper.Selector) bool {
if n.Type != html.ElementNode {
return false
}
if sel.Tag != "" && n.Data != sel.Tag {
return false
}
if sel.ID != "" {
for _, a := range n.Attr {
if a.Key == "id" && a.Val == sel.ID {
goto checkClass
}
}
return false
}
checkClass:
if sel.Class != "" {
for _, a := range n.Attr {
if a.Key == "class" {
for _, cls := range strings.Fields(a.Val) {
if cls == sel.Class {
goto matched
}
}
}
}
return false
}
matched:
return true
}
// AttrVal returns the value of attribute key from node n.
func AttrVal(n *html.Node, key string) string {
for _, a := range n.Attr {
if a.Key == key {
return a.Val
}
}
return ""
}
// TextContent returns the concatenated text content of all descendant text nodes.
func TextContent(n *html.Node) string {
var sb strings.Builder
var walk func(*html.Node)
walk = func(cur *html.Node) {
if cur.Type == html.TextNode {
sb.WriteString(cur.Data)
}
for c := cur.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return strings.TrimSpace(sb.String())
}
// FindFirst returns the first node matching sel within root.
func FindFirst(root *html.Node, sel scraper.Selector) *html.Node {
var found *html.Node
var walk func(*html.Node) bool
walk = func(n *html.Node) bool {
if selectorMatches(n, sel) {
found = n
return true
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if walk(c) {
return true
}
}
return false
}
walk(root)
return found
}
// FindAll returns all nodes matching sel within root.
func FindAll(root *html.Node, sel scraper.Selector) []*html.Node {
var results []*html.Node
var walk func(*html.Node)
walk = func(n *html.Node) {
if selectorMatches(n, sel) {
results = append(results, n)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
return results
}
// ExtractText extracts a string value from node n using sel.
// If sel.Attr is set the attribute value is returned; otherwise the inner text.
func ExtractText(n *html.Node, sel scraper.Selector) string {
if sel.Attr != "" {
return AttrVal(n, sel.Attr)
}
return TextContent(n)
}
// ExtractFirst locates the first match in root and returns its text/attr value.
func ExtractFirst(root *html.Node, sel scraper.Selector) string {
n := FindFirst(root, sel)
if n == nil {
return ""
}
return ExtractText(n, sel)
}
// ExtractAll locates all matches in root and returns their text/attr values.
func ExtractAll(root *html.Node, sel scraper.Selector) []string {
nodes := FindAll(root, sel)
out := make([]string, 0, len(nodes))
for _, n := range nodes {
if v := ExtractText(n, sel); v != "" {
out = append(out, v)
}
}
return out
}
// NodeToMarkdown converts the children of an HTML node to a plain-text/Markdown
// representation suitable for chapter storage.
func NodeToMarkdown(n *html.Node) string {
var sb strings.Builder
nodeToMD(n, &sb)
out := multiBlankLine.ReplaceAllString(sb.String(), "\n\n")
return strings.TrimSpace(out)
}
var multiBlankLine = regexp.MustCompile(`\n(\s*\n){2,}`)
var blockElements = map[string]bool{
"p": true, "div": true, "br": true, "h1": true, "h2": true,
"h3": true, "h4": true, "h5": true, "h6": true, "li": true,
"blockquote": true, "pre": true, "hr": true,
}
func nodeToMD(n *html.Node, sb *strings.Builder) {
switch n.Type {
case html.TextNode:
sb.WriteString(n.Data)
case html.ElementNode:
tag := n.Data
switch tag {
case "br":
sb.WriteString("\n")
case "hr":
sb.WriteString("\n---\n")
case "h1", "h2", "h3", "h4", "h5", "h6":
level := int(tag[1] - '0')
sb.WriteString("\n" + strings.Repeat("#", level) + " ")
for c := n.FirstChild; c != nil; c = c.NextSibling {
nodeToMD(c, sb)
}
sb.WriteString("\n\n")
return
case "p", "div", "blockquote":
sb.WriteString("\n")
for c := n.FirstChild; c != nil; c = c.NextSibling {
nodeToMD(c, sb)
}
sb.WriteString("\n")
return
case "em", "i":
sb.WriteString("*")
for c := n.FirstChild; c != nil; c = c.NextSibling {
nodeToMD(c, sb)
}
sb.WriteString("*")
return
case "strong", "b":
sb.WriteString("**")
for c := n.FirstChild; c != nil; c = c.NextSibling {
nodeToMD(c, sb)
}
sb.WriteString("**")
return
case "script", "style", "noscript":
return // drop
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
nodeToMD(c, sb)
}
if blockElements[tag] {
sb.WriteString("\n")
}
}
}