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
- 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
229 lines
5.4 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|