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 }