Files
next-book/pkg/storygraph/client.go
2026-03-01 10:39:09 +00:00

242 lines
5.2 KiB
Go

package storygraph
import (
"fmt"
"iter"
"maps"
"os"
"slices"
"sort"
"strings"
"github.com/joho/godotenv"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
var (
c *HTTPClient
)
type Book struct {
ID string
Name string
Link string
Image string
Rating string
Tags []string
}
func init() {
godotenv.Load(os.Getenv("DOTENV_DIR") + ".env")
cookie := os.Getenv("COOKIE")
if cookie == "" {
panic("COOKIE is not set")
}
c = New(cookie)
}
func GetLatestBooks() (map[string]*[]Book, error) {
fmt.Println("Fetching latest book recommendations...")
links := []Book{}
page := 0
for {
page++
fmt.Println("Fetching page", page)
resp, err := c.Get(fmt.Sprintf("https://app.thestorygraph.com/to-read/averagemarcus?per_page=100&page=%d", page))
if err != nil {
fmt.Println("Error making request:", err)
return nil, err
}
defer resp.Body.Close()
doc, err := html.Parse(resp.Body)
if err != nil {
return nil, err
}
bookFound := false
for n := range doc.Descendants() {
if n.Type == html.ElementNode && n.DataAtom == atom.Div {
for _, a := range n.Attr {
if a.Key == "data-book-id" {
bookFound = true
bookID := a.Val
name := getName(n.Descendants())
link := fmt.Sprintf("https://app.thestorygraph.com/books/%s", bookID)
tags := getTags(n.Descendants())
image := getImage(n.Descendants())
if !bookContains(links, bookID) {
links = append(links, Book{
ID: bookID,
Name: name,
Link: link,
Image: image,
Rating: getRating(bookID),
Tags: tags,
})
}
}
}
}
}
if !bookFound {
break
}
}
sort.Slice(links, func(i, j int) bool {
return links[i].Rating > links[j].Rating
})
return map[string]*[]Book{
"Fiction": nextByTag(links, "Fiction"),
"Non-Fiction": nextByTag(links, "Non-Fiction"),
"Health": nextByTag(links, "Health"),
"Art": nextByTag(links, "Art"),
"Business": nextByTag(links, "Business"),
"Technology": nextByTag(links, "Technology"),
"Sci-Fi": nextByTag(links, "Sci-Fi"),
"Fantasy": nextByTag(links, "Fantasy"),
"Comics": nextByTag(links, "Comics"),
}, nil
}
func getRating(bookID string) string {
resp, err := c.Get(fmt.Sprintf("https://app.thestorygraph.com/books/%s/community_reviews", bookID))
if err != nil {
fmt.Println("Error fetching book rating:", err)
return "0.0"
}
defer resp.Body.Close()
doc, err := html.Parse(resp.Body)
if err != nil {
panic(err)
}
for n := range doc.Descendants() {
if n.Type == html.ElementNode && n.DataAtom == atom.Span {
for _, a := range n.Attr {
if a.Key == "class" && strings.Contains(a.Val, "average-star-rating") {
for t := range n.Descendants() {
if t.Type == html.TextNode {
return strings.TrimSpace(t.Data)
}
}
}
}
}
}
fmt.Println("Error fetching book rating: no rating found for book", bookID)
return "0.0"
}
func getName(decs iter.Seq[*html.Node]) string {
for n := range decs {
if n.Type == html.ElementNode && n.DataAtom == atom.Img {
for _, a := range n.Attr {
if a.Key == "alt" {
return a.Val
}
}
}
}
return ""
}
func getImage(decs iter.Seq[*html.Node]) string {
for n := range decs {
if n.Type == html.ElementNode && n.DataAtom == atom.Img {
for _, a := range n.Attr {
if a.Key == "src" {
return a.Val
}
}
}
}
return ""
}
func getTags(decs iter.Seq[*html.Node]) []string {
tags := map[string]bool{}
for n := range decs {
if n.Type == html.ElementNode && n.DataAtom == atom.Div {
for _, a := range n.Attr {
if a.Key == "class" && strings.Contains(a.Val, "book-pane-tag-section") {
for t := range n.Descendants() {
if t.Type == html.ElementNode && (t.DataAtom == atom.Span || t.DataAtom == atom.A) {
for b := range t.Descendants() {
if b.Type == html.TextNode {
switch b.Data {
case "fiction":
tags["Fiction"] = true
case "nonfiction":
tags["Non-Fiction"] = true
case "psychology":
fallthrough
case "self help":
fallthrough
case "health":
tags["Health"] = true
case "art":
tags["Art"] = true
case "business":
tags["Business"] = true
case "technology":
fallthrough
case "computer science":
tags["Technology"] = true
case "science fiction":
tags["Sci-Fi"] = true
case "fantasy":
tags["Fantasy"] = true
case "comics":
fallthrough
case "graphic novels":
fallthrough
case "manga":
tags["Comics"] = true
}
}
}
}
}
}
}
}
}
// Only include comics in the "comics" category
if tags["Comics"] {
tags = map[string]bool{"Comics": true}
}
return slices.Collect(maps.Keys(tags))
}
func bookContains(links []Book, bookID string) bool {
for _, b := range links {
if b.ID == bookID {
return true
}
}
return false
}
func nextByTag(links []Book, tag string) *[]Book {
filtered := []Book{}
for _, b := range links {
if slices.Contains(b.Tags, tag) {
filtered = append(filtered, b)
}
}
return &filtered
}