commit 9425aaae0f48f7d8aabd57a1d7e4a21312155f80 Author: Tordarus Date: Sun Aug 17 11:45:23 2025 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a48d947 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*_test.go diff --git a/api.go b/api.go new file mode 100644 index 0000000..4d87421 --- /dev/null +++ b/api.go @@ -0,0 +1,223 @@ +package nuapi + +import ( + "context" + "fmt" + "os" + "path" + "strconv" + "strings" + "time" + + "git.tordarus.net/tordarus/adverr/v2" + "git.tordarus.net/tordarus/slices" + "github.com/PuerkitoBio/goquery" +) + +type Api struct { + UserAgent string + Cookie string +} + +func NewApi(cookie string) *Api { + return &Api{ + Cookie: cookie, + UserAgent: "Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0", + } +} + +func (api *Api) GetReadingList(ctx context.Context, listIndex int) (*ReadingList, error) { + doc, err := api.GetWithCookie(ctx, fmt.Sprintf("https://www.novelupdates.com/reading-list/?list=%d", listIndex)) + if err != nil { + return nil, ErrApiRequestFailed.Wrap(err) + } + + listID := ReadingListID(doc.Find("#cssmenu ul li.active a").Text()) + + selection := doc.Find("table tbody tr") + entries := make([]ReadingListEntry, 0, selection.Length()) + selection.Each(func(i int, s *goquery.Selection) { + link := s.Find("td:nth-child(2) a:first-child") + href, ok := link.Attr("href") + if !ok { + return + } + + novelID := NovelID(path.Base(href)) + novel := NovelEntry{ + NovelID: novelID, + Name: link.Text(), + } + + currentChapterLink := s.Find("td:nth-child(3) a") + currentChapter := ReadingListChapterEntry{ + NovelID: novelID, + ID: ChapterID(currentChapterLink.Text()), + Link: currentChapterLink.AttrOr("href", ""), + } + + latestChapterLink := s.Find("td:nth-child(3) a") + latestChapter := ReadingListChapterEntry{ + NovelID: novelID, + ID: ChapterID(latestChapterLink.Text()), + Link: latestChapterLink.AttrOr("href", ""), + } + + entries = append(entries, ReadingListEntry{ + Novel: novel, + CurrentChapter: currentChapter, + LatestChapter: latestChapter, + }) + }) + + return &ReadingList{ + ID: listID, + Entries: entries, + }, nil +} + +func (api *Api) GetNovelByID(novelID NovelID) (*Novel, error) { + doc, err := api.Get(fmt.Sprintf("https://www.novelupdates.com/series/%s/", novelID)) + if err != nil { + return nil, ErrApiRequestFailed.Wrap(err) + } + + title := doc.Find(".seriestitlenu").Text() + description := strings.TrimSpace(doc.Find("#editdescription").Text()) + cover := doc.Find(".wpb_wrapper img").AttrOr("src", "") + + associatedNamesHtml, err := doc.Find("#editassociated").Html() + if err != nil { + return nil, ErrApiElementNotFound.Wrap(err, "#editassociated") + } + associatedNames := strings.Split(strings.TrimSpace(associatedNamesHtml), "
") + + novelType := NovelType(doc.Find("#showtype a.genre.type").Text()) + originalLanguage := Language(strings.ToLower(strings.Trim(doc.Find("#showtype a.genre.type + span").Text(), "()"))) + + genreElems := doc.Find("#seriesgenre a.genre") + genres := make([]GenreID, 0, genreElems.Length()) + genreElems.Each(func(i int, s *goquery.Selection) { + href, ok := s.Attr("href") + if !ok { + return + } + genres = append(genres, GenreID(path.Base(href))) + }) + + tagElems := doc.Find("#showtags a.genre") + tags := make([]TagID, 0, genreElems.Length()) + tagElems.Each(func(i int, s *goquery.Selection) { + href, ok := s.Attr("href") + if !ok { + return + } + tags = append(tags, TagID(path.Base(href))) + }) + + return &Novel{ + ID: novelID, + Name: title, + AssociatedNames: associatedNames, + Description: description, + Cover: cover, + Type: novelType, + OriginalLanguage: originalLanguage, + Genres: genres, + Tags: tags, + }, nil +} + +func (api *Api) GetChapterEntriesByNovelID(novelID NovelID) *Cursor[NovelChapterEntry] { + ctx, cancelFunc := context.WithCancel(context.Background()) + out := make(chan *NovelChapterEntry, 15) + + go func() { + defer close(out) + + doc, err := api.Get(fmt.Sprintf("https://www.novelupdates.com/series/%s/?pg=%d", novelID, 1)) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + + pageCount, err := api.getPageCount(doc) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + + for pageIndex := pageCount; pageIndex > 0; pageIndex-- { + if ctx.Err() != nil { + break + } + + entries, err := api.getChapterEntriesByPageIndex(novelID, pageIndex) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + + for _, entry := range entries { + entry := entry + out <- &entry + } + } + }() + + return &Cursor[NovelChapterEntry]{ + ctx: ctx, + cancelFunc: cancelFunc, + Chan: out, + } +} + +func (api *Api) getPageCount(doc *goquery.Document) (int, error) { + pagination := doc.Find(".digg_pagination") + if pagination.Length() <= 0 { + return 1, nil + } + + pageCount, err := strconv.ParseInt(pagination.Find("a:nth-last-child(2)").Text(), 10, 64) + if err != nil { + adverr.Println(ErrApiElementNotFound.Wrap(err, ".digg_pagination a:nth-child(5)")) + return 0, err + } + + return int(pageCount), nil +} + +func (api *Api) getChapterEntriesByPageIndex(novelID NovelID, pageIndex int) ([]NovelChapterEntry, error) { + doc, err := api.Get(fmt.Sprintf("https://www.novelupdates.com/series/%s/?pg=%d", novelID, pageIndex)) + if err != nil { + return nil, ErrApiRequestFailed.Wrap(err) + } + + entryElems := doc.Find("#myTable tbody tr") + entries := make([]NovelChapterEntry, 0, entryElems.Length()) + entryElems.Each(func(i int, s *goquery.Selection) { + td3 := s.Find("td:nth-child(3) a") + + fmt.Printf("%#v\n", adverr.Must(s.Find("td:nth-child(3)").Html())) + + chapterID := strings.TrimSpace(td3.Text()) + groupID := path.Base(s.Find("td:nth-child(2) a").AttrOr("href", "")) + link := "https:" + td3.AttrOr("href", "") + + date, err := time.Parse("01/02/2006", strings.TrimSpace(s.Find("td:first-child").Text())) + if err != nil { + adverr.Println(ErrApiElementNotFound.Wrap(err, "td:first-child")) + return + } + + entries = append(entries, NovelChapterEntry{ + NovelID: novelID, + ID: chapterID, + Link: link, + Date: date, + Group: groupID, + }) + }) + + return slices.Reverse(entries), nil +} diff --git a/curl.go b/curl.go new file mode 100644 index 0000000..b9d1a47 --- /dev/null +++ b/curl.go @@ -0,0 +1,70 @@ +package nuapi + +import ( + "compress/gzip" + "context" + "fmt" + "os/exec" + + "github.com/PuerkitoBio/goquery" +) + +func (api *Api) GetWithCookie(ctx context.Context, url string) (*goquery.Document, error) { + if api.Cookie == "" { + return nil, ErrNoCookieSet.New() + } + + curl := exec.Command("curl", + "-s", url, + "-H", fmt.Sprintf("User-Agent: %s", api.UserAgent), + "-H", fmt.Sprintf("Cookie: %s", api.Cookie), + "-H", fmt.Sprintf("Accept-Encoding: %s", "gzip"), + ) + + stdout, err := curl.StdoutPipe() + if err != nil { + return nil, ErrCurlRequestFailed.Wrap(err, url) + } + + if err := curl.Start(); err != nil { + return nil, ErrCurlRequestFailed.Wrap(err, url) + } + defer curl.Wait() + + r, err := gzip.NewReader(stdout) + if err != nil { + return nil, ErrInvalidGzipData.New(err) + } + + return goquery.NewDocumentFromReader(r) +} + +func (api *Api) Get(url string) (*goquery.Document, error) { + if api.Cookie == "" { + return nil, ErrNoCookieSet.New() + } + + curl := exec.Command("curl", + "-s", url, + "-H", fmt.Sprintf("Cookie: %s", api.Cookie), + "-H", fmt.Sprintf("User-Agent: %s", api.UserAgent), + "-H", fmt.Sprintf("Accept-Encoding: %s", "gzip"), + ) + + stdout, err := curl.StdoutPipe() + if err != nil { + return nil, ErrCurlRequestFailed.Wrap(err, url) + } + + if err := curl.Start(); err != nil { + return nil, ErrCurlRequestFailed.Wrap(err, url) + } + defer curl.Wait() + + r, err := gzip.NewReader(stdout) + if err != nil { + return nil, ErrInvalidGzipData.Wrap(err) + } + + return goquery.NewDocumentFromReader(r) +} diff --git a/cursor.go b/cursor.go new file mode 100644 index 0000000..319a328 --- /dev/null +++ b/cursor.go @@ -0,0 +1,37 @@ +package nuapi + +import ( + "context" +) + +type Cursor[T any] struct { + ctx context.Context + cancelFunc context.CancelFunc + Chan <-chan *T +} + +func (c *Cursor[T]) First() *T { + defer c.cancelFunc() + return <-c.Chan +} + +func (c *Cursor[T]) Close() { + c.cancelFunc() +} + +func (c *Cursor[T]) Next() (*T, bool) { + if c.ctx.Err() != nil { + return nil, false + } + + value, ok := <-c.Chan + return value, ok +} + +func (c *Cursor[T]) Slice() []T { + s := make([]T, 0) + for value, ok := c.Next(); ok; value, ok = c.Next() { + s = append(s, *value) + } + return s +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..d37dada --- /dev/null +++ b/errors.go @@ -0,0 +1,11 @@ +package nuapi + +import "git.tordarus.net/tordarus/adverr/v2" + +var ( + ErrNoCookieSet = adverr.NewErrTmpl("ErrNoCookieSet", "no API cookie set") + ErrCurlRequestFailed = adverr.NewErrTmpl("ErrCurlRequestFailed", "curl request failed for url: '%s'") + ErrInvalidGzipData = adverr.NewErrTmpl("ErrInvalidGzipData", "gzip encoded data expected") + ErrApiRequestFailed = adverr.NewErrTmpl("ErrApiRequestFailed", "Data retrieval from NU failed") + ErrApiElementNotFound = adverr.NewErrTmpl("ErrApiElementNotFound", "element not found: '%s'") +) diff --git a/genre.go b/genre.go new file mode 100644 index 0000000..4b1eab8 --- /dev/null +++ b/genre.go @@ -0,0 +1,195 @@ +package nuapi + +type GenreID string + +const ( + GenreAction GenreID = "action" + GenreAdult GenreID = "adult" + GenreAdventure GenreID = "adventure" + GenreComedy GenreID = "comedy" + GenreDrama GenreID = "drama" + GenreEcchi GenreID = "ecchi" + GenreFantasy GenreID = "fantasy" + GenreGenderBender GenreID = "gender-bender" + GenreHarem GenreID = "harem" + GenreHistorical GenreID = "historical" + GenreHorror GenreID = "horror" + GenreJosei GenreID = "josei" + GenreMartialArts GenreID = "martial-arts" + GenreMature GenreID = "mature" + GenreMecha GenreID = "mecha" + GenreMystery GenreID = "mystery" + GenrePsychological GenreID = "psychological" + GenreRomance GenreID = "romance" + GenreSchoolLife GenreID = "school-life" + GenreSciFi GenreID = "sci-fi" + GenreSeinen GenreID = "seinen" + GenreShoujo GenreID = "shoujo" + GenreShoujoAi GenreID = "shoujo-ai" + GenreShounen GenreID = "shounen" + GenreShounenAi GenreID = "shounen-ai" + GenreSliceOfLife GenreID = "slice-of-life" + GenreSmut GenreID = "smut" + GenreSports GenreID = "sports" + GenreSupernatural GenreID = "supernatural" + GenreTragedy GenreID = "tragedy" + GenreWuxia GenreID = "wuxia" + GenreXianxia GenreID = "xianxia" + GenreXuanhuan GenreID = "xuanhuan" + GenreYaoi GenreID = "yaoi" + GenreYuri GenreID = "yuri" +) + +func (g GenreID) String() string { + switch g { + case GenreAction: + return "Action" + case GenreAdult: + return "Adult" + case GenreAdventure: + return "Adventure" + case GenreComedy: + return "Comedy" + case GenreDrama: + return "Drama" + case GenreEcchi: + return "Ecchi" + case GenreFantasy: + return "Fantasy" + case GenreGenderBender: + return "Gender Bender" + case GenreHarem: + return "Harem" + case GenreHistorical: + return "Historical" + case GenreHorror: + return "Horror" + case GenreJosei: + return "Josei" + case GenreMartialArts: + return "Martial Arts" + case GenreMature: + return "Mature" + case GenreMecha: + return "Mecha" + case GenreMystery: + return "Mystery" + case GenrePsychological: + return "Psychological" + case GenreRomance: + return "Romance" + case GenreSchoolLife: + return "School Life" + case GenreSciFi: + return "Sci-fi" + case GenreSeinen: + return "Seinen" + case GenreShoujo: + return "Shoujo" + case GenreShoujoAi: + return "Shoujo Ai" + case GenreShounen: + return "Shounen" + case GenreShounenAi: + return "Shounen Ai" + case GenreSliceOfLife: + return "Slice of Life" + case GenreSmut: + return "Smut" + case GenreSports: + return "Sports" + case GenreSupernatural: + return "Supernatural" + case GenreTragedy: + return "Tragedy" + case GenreWuxia: + return "Wuxia" + case GenreXianxia: + return "Xianxia" + case GenreXuanhuan: + return "Xuanhuan" + case GenreYaoi: + return "Yaoi" + case GenreYuri: + return "Yuri" + default: + panic("invalid genre: " + g) + } +} + +func ParseGenre(str string) GenreID { + switch str { + case "Action": + return GenreAction + case "Adult": + return GenreAdult + case "Adventure": + return GenreAdventure + case "Comedy": + return GenreComedy + case "Drama": + return GenreDrama + case "Ecchi": + return GenreEcchi + case "Fantasy": + return GenreFantasy + case "Gender Bender": + return GenreGenderBender + case "Harem": + return GenreHarem + case "Historical": + return GenreHistorical + case "Horror": + return GenreHorror + case "Josei": + return GenreJosei + case "Martial Arts": + return GenreMartialArts + case "Mature": + return GenreMature + case "Mecha": + return GenreMecha + case "Mystery": + return GenreMystery + case "Psychological": + return GenrePsychological + case "Romance": + return GenreRomance + case "School Life": + return GenreSchoolLife + case "Sci-fi": + return GenreSciFi + case "Seinen": + return GenreSeinen + case "Shoujo": + return GenreShoujo + case "Shoujo Ai": + return GenreShoujoAi + case "Shounen": + return GenreShounen + case "Shounen Ai": + return GenreShounenAi + case "Slice of Life": + return GenreSliceOfLife + case "Smut": + return GenreSmut + case "Sports": + return GenreSports + case "Supernatural": + return GenreSupernatural + case "Tragedy": + return GenreTragedy + case "Wuxia": + return GenreWuxia + case "Xianxia": + return GenreXianxia + case "Xuanhuan": + return GenreXuanhuan + case "Yaoi": + return GenreYaoi + case "Yuri": + return GenreYuri + default: + panic("invalid genre: " + str) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f5005b4 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.tordarus.net/tordarus/nu-api + +go 1.23 + +toolchain go1.24.6 + +require ( + git.tordarus.net/tordarus/adverr/v2 v2.0.2 + git.tordarus.net/tordarus/slices v0.0.15 + github.com/PuerkitoBio/goquery v1.8.1 +) + +require ( + git.tordarus.net/tordarus/gmath v0.0.7 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect + golang.org/x/net v0.7.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d6571dc --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +git.tordarus.net/tordarus/adverr/v2 v2.0.2 h1:7nvNjMMjtGPq0EY6duMiv+seJ7MacNvKSBmckHl6Erg= +git.tordarus.net/tordarus/adverr/v2 v2.0.2/go.mod h1:gCC46KsWosZJh7MVNDEU99hKQoxEWZgHITDHtmFwwiQ= +git.tordarus.net/tordarus/gmath v0.0.7 h1:tR48idt9AUL0r556ww3ZxByTKJEr6NWCTlhl2ihzYxQ= +git.tordarus.net/tordarus/gmath v0.0.7/go.mod h1:mO7aPlvNrGVE9UFXEuuACjZgMDsM63l3OcQy6xSQnoE= +git.tordarus.net/tordarus/slices v0.0.15 h1:qxKS+7BCZ/LzQbRCvdDFoLQXaJZ2C0GfVju/kPt1r3g= +git.tordarus.net/tordarus/slices v0.0.15/go.mod h1:eJBw6pSDNivPI0l4e0sGKUJzou/lbzHflXdAUzp1g4o= +github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= +github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/tag.go b/tag.go new file mode 100644 index 0000000..b7df9b7 --- /dev/null +++ b/tag.go @@ -0,0 +1,3 @@ +package nuapi + +type TagID string diff --git a/types.go b/types.go new file mode 100644 index 0000000..8edb2b0 --- /dev/null +++ b/types.go @@ -0,0 +1,109 @@ +package nuapi + +import ( + "encoding/json" + "time" +) + +type NovelID = string +type ChapterID = string +type ReadingListID = string +type GroupID = string + +type ReadingList struct { + ID ReadingListID `json:"id"` + Entries []ReadingListEntry `json:"entries"` +} + +func (l ReadingList) String() string { + data, _ := json.MarshalIndent(l, "", "\t") + return string(data) +} + +type ReadingListEntry struct { + Novel NovelEntry `json:"novel"` + CurrentChapter ReadingListChapterEntry `json:"current_chapter"` + LatestChapter ReadingListChapterEntry `json:"latest_chapter"` +} + +func (e ReadingListEntry) String() string { + data, _ := json.MarshalIndent(e, "", "\t") + return string(data) +} + +func (e ReadingListEntry) NewChapterAvailable() bool { + return e.CurrentChapter.ID != e.LatestChapter.ID +} + +type ReadingListChapterEntry struct { + NovelID NovelID `json:"novel_id"` + ID ChapterID `json:"id"` + Link string `json:"link"` +} + +func (e ReadingListChapterEntry) String() string { + data, _ := json.MarshalIndent(e, "", "\t") + return string(data) +} + +type NovelEntry struct { + NovelID NovelID `json:"id"` + Name string `json:"name"` +} + +func (e NovelEntry) String() string { + data, _ := json.MarshalIndent(e, "", "\t") + return string(data) +} + +type Novel struct { + ID NovelID `json:"id"` + Name string `json:"name"` + AssociatedNames []string `json:"associated_names"` + Description string `json:"description"` + Cover string `json:"cover"` + Type NovelType `json:"type"` + OriginalLanguage Language `json:"original_language"` + Genres []GenreID `json:"genres"` + Tags []TagID `json:"tags"` +} + +func (n Novel) String() string { + data, _ := json.MarshalIndent(n, "", "\t") + return string(data) +} + +type NovelType string + +const ( + TypeLightNovel NovelType = "Light Novel" + TypePublishedNovel NovelType = "Published Novel" + TypeWebNovel NovelType = "Web Novel" +) + +type Language string + +const ( + LanguageJapanese Language = "jp" + LanguageChinese Language = "cn" + LanguageMalaysian Language = "my" + LanguageFilipino Language = "fil" + LanguageKhmer Language = "khm" + LanguageThai Language = "th" + LanguageIndonesian Language = "id" + LanguageKorean Language = "kr" + LanguageVietnamese Language = "vn" +) + +type NovelChapterEntry struct { + ID ChapterID `json:"id"` + Link string `json:"link"` + NovelID NovelID `json:"novel_id"` + Date time.Time `json:"date"` + Group GroupID `json:"group"` +} + +func (nce NovelChapterEntry) String() string { + data, _ := json.MarshalIndent(nce, "", "\t") + return string(data) +}