initial commit
This commit is contained in:
		
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
			
		||||
*_test.go
 | 
			
		||||
							
								
								
									
										223
									
								
								api.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								api.go
									
									
									
									
									
										Normal file
									
								
							@ -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), "<br/>")
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										70
									
								
								curl.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								curl.go
									
									
									
									
									
										Normal file
									
								
							@ -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)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										37
									
								
								cursor.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								cursor.go
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								errors.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								errors.go
									
									
									
									
									
										Normal file
									
								
							@ -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'")
 | 
			
		||||
)
 | 
			
		||||
							
								
								
									
										195
									
								
								genre.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								genre.go
									
									
									
									
									
										Normal file
									
								
							@ -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)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										17
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
)
 | 
			
		||||
							
								
								
									
										41
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							@ -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=
 | 
			
		||||
							
								
								
									
										109
									
								
								types.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								types.go
									
									
									
									
									
										Normal file
									
								
							@ -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)
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user