ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile. Co-authored-by: Cursor <cursoragent@cursor.com>
175 lines
4.0 KiB
Go
175 lines
4.0 KiB
Go
package genius
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
// Parse only designated lyrics containers, never the entire page (navigation, ads, annotations).
|
|
func parseLyrics(body []byte) (string, error) {
|
|
doc, err := html.Parse(bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var blocks []string
|
|
var walk func(*html.Node)
|
|
walk = func(n *html.Node) {
|
|
if excluded(n) {
|
|
return
|
|
}
|
|
if attr(n, "data-lyrics-container") == "true" {
|
|
if text := nodeText(n); text != "" {
|
|
blocks = append(blocks, text)
|
|
}
|
|
return
|
|
}
|
|
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
|
walk(child)
|
|
}
|
|
}
|
|
walk(doc)
|
|
if len(blocks) == 0 {
|
|
if text := embeddedLyrics(doc); text != "" {
|
|
return text, nil
|
|
}
|
|
return "", ErrLyricsUnavailable
|
|
}
|
|
return strings.Join(blocks, "\n\n"), nil
|
|
}
|
|
|
|
func attr(n *html.Node, name string) string {
|
|
for _, a := range n.Attr {
|
|
if a.Key == name {
|
|
return a.Val
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func excluded(n *html.Node) bool {
|
|
return n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style" || n.Data == "button" ||
|
|
attr(n, "data-exclude-from-selection") == "true" || attr(n, "aria-hidden") == "true" ||
|
|
strings.Contains(attr(n, "class"), "LyricsHeader"))
|
|
}
|
|
|
|
var statePattern = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*JSON\.parse\('((?:[^'\\]|\\.)*)'\)`)
|
|
|
|
// Read a JSON string literal only; never evaluate JavaScript from a remote page.
|
|
func embeddedLyrics(doc *html.Node) string {
|
|
var result string
|
|
var walk func(*html.Node)
|
|
walk = func(n *html.Node) {
|
|
if result != "" {
|
|
return
|
|
}
|
|
if n.Type == html.ElementNode && n.Data == "script" && n.FirstChild != nil {
|
|
match := statePattern.FindStringSubmatch(n.FirstChild.Data)
|
|
if len(match) == 2 {
|
|
decoded, ok := decodeJSString(match[1])
|
|
if ok {
|
|
var state struct {
|
|
SongPage struct {
|
|
LyricsData struct {
|
|
Body struct {
|
|
HTML string `json:"html"`
|
|
} `json:"body"`
|
|
} `json:"lyricsData"`
|
|
} `json:"songPage"`
|
|
}
|
|
if json.Unmarshal([]byte(decoded), &state) == nil {
|
|
fragment, err := html.Parse(strings.NewReader(strings.ReplaceAll(state.SongPage.LyricsData.Body.HTML, "\n", "")))
|
|
if err == nil {
|
|
result = nodeText(fragment)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
|
walk(child)
|
|
}
|
|
}
|
|
walk(doc)
|
|
return result
|
|
}
|
|
|
|
func decodeJSString(raw string) (string, bool) {
|
|
var quoted strings.Builder
|
|
quoted.WriteByte('"')
|
|
for i := 0; i < len(raw); i++ {
|
|
switch raw[i] {
|
|
case '"':
|
|
quoted.WriteString(`\"`)
|
|
case '\\':
|
|
i++
|
|
if i >= len(raw) {
|
|
return "", false
|
|
}
|
|
switch raw[i] {
|
|
case '\'':
|
|
quoted.WriteByte('\'')
|
|
case 'x':
|
|
if i+2 >= len(raw) {
|
|
return "", false
|
|
}
|
|
quoted.WriteString(`\u00`)
|
|
quoted.WriteString(raw[i+1 : i+3])
|
|
i += 2
|
|
default:
|
|
quoted.WriteByte('\\')
|
|
quoted.WriteByte(raw[i])
|
|
}
|
|
default:
|
|
quoted.WriteByte(raw[i])
|
|
}
|
|
}
|
|
quoted.WriteByte('"')
|
|
var decoded string
|
|
err := json.Unmarshal([]byte(quoted.String()), &decoded)
|
|
return decoded, err == nil
|
|
}
|
|
|
|
func nodeText(root *html.Node) string {
|
|
var out strings.Builder
|
|
var walk func(*html.Node)
|
|
walk = func(n *html.Node) {
|
|
if excluded(n) {
|
|
return
|
|
}
|
|
if n.Type == html.TextNode {
|
|
out.WriteString(n.Data)
|
|
return
|
|
}
|
|
if n.Type == html.ElementNode && n.Data == "br" {
|
|
out.WriteByte('\n')
|
|
return
|
|
}
|
|
block := n.Type == html.ElementNode && (n.Data == "p" || n.Data == "div") && n != root
|
|
if block {
|
|
out.WriteByte('\n')
|
|
}
|
|
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
|
walk(child)
|
|
}
|
|
if block {
|
|
out.WriteByte('\n')
|
|
}
|
|
}
|
|
walk(root)
|
|
lines := strings.Split(strings.ReplaceAll(out.String(), "\r", ""), "\n")
|
|
clean := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
line = strings.Join(strings.Fields(line), " ")
|
|
if line == "" && (len(clean) == 0 || clean[len(clean)-1] == "") {
|
|
continue
|
|
}
|
|
clean = append(clean, line)
|
|
}
|
|
return strings.TrimSpace(strings.Join(clean, "\n"))
|
|
}
|