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")) }