commit 9a098b205a1d2e979bb92cb33e0bffd2385aa81f Author: nite Date: Sun Apr 20 00:32:41 2025 +1000 start diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fcff2aa --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +novels/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e0ab32a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug download volume", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}", + "args": ["download", "volume", "-n", "2013", "-v", "165880"] + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e055743 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# BiliNovel Downloader + +这是一个用于下载和生成轻小说 EPUB 电子书的工具。 + +## 功能特点 + +- 支持下载轻小说并转换为标准 EPUB 格式 +- 自动处理图片和文本内容 +- 生成符合 EPUB 3.0 规范的电子书文件 +- 支持多章节内容的组织和管理 +- 保留原有插图和排版格式 + +## 使用说明 + +1. 确保系统环境满足要求 +2. 运行下载器获取小说内容 +3. 程序会自动处理并生成标准格式的 EPUB 文件 +4. 生成的电子书文件可以在任何支持 EPUB 3.0 的阅读器中打开 + +## 注意事项 + +- 生成的 EPUB 文件严格遵循 EPUB 3.0 规范 +- 建议使用支持 EPUB 3.0 的阅读器以获得最佳阅读体验 +- 请遵守相关法律法规,合理使用下载的内容 + +## 技术规范 + +- 使用 EPUB 3.0 标准 +- 支持 UTF-8 编码 +- 支持繁体中文内容 +- 包含元数据管理 diff --git a/cmd/download.go b/cmd/download.go new file mode 100644 index 0000000..fc1dd97 --- /dev/null +++ b/cmd/download.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "bilinovel-downloader/downloader" + "fmt" + + "github.com/spf13/cobra" +) + +var downloadCmd = &cobra.Command{ + Use: "download", +} + +var downloadNovelCmd = &cobra.Command{ + Use: "novel", + Short: "Download a novel, default download all volumes", + Long: "Download a novel, default download all volumes", + RunE: runDownloadNovel, +} + +var downloadVolumeCmd = &cobra.Command{ + Use: "volume", + Short: "Download a volume", + Long: "Download a volume", + RunE: runDownloadVolume, +} + +type downloadNovelArgs struct { + NovelId int `validate:"required"` + outputPath string +} + +type downloadVolumeArgs struct { + NovelId int `validate:"required"` + VolumeId int `validate:"required"` + outputPath string +} + +var ( + novelArgs downloadNovelArgs + volumeArgs downloadVolumeArgs +) + +func init() { + downloadNovelCmd.Flags().IntVarP(&novelArgs.NovelId, "novel-id", "n", 0, "novel id") + downloadNovelCmd.Flags().StringVarP(&novelArgs.outputPath, "output-path", "o", "./novels", "output path") + + downloadVolumeCmd.Flags().IntVarP(&volumeArgs.NovelId, "novel-id", "n", 0, "novel id") + downloadVolumeCmd.Flags().IntVarP(&volumeArgs.VolumeId, "volume-id", "v", 0, "volume id") + downloadVolumeCmd.Flags().StringVarP(&volumeArgs.outputPath, "output-path", "o", "./novels", "output path") + + downloadCmd.AddCommand(downloadNovelCmd) + downloadCmd.AddCommand(downloadVolumeCmd) + RootCmd.AddCommand(downloadCmd) +} + +func runDownloadNovel(cmd *cobra.Command, args []string) error { + if novelArgs.NovelId == 0 { + return fmt.Errorf("novel id is required") + } + err := downloader.DownloadNovel(novelArgs.NovelId, novelArgs.outputPath) + if err != nil { + return fmt.Errorf("failed to download novel: %v", err) + } + + return nil +} + +func runDownloadVolume(cmd *cobra.Command, args []string) error { + if volumeArgs.NovelId == 0 { + return fmt.Errorf("novel id is required") + } + if volumeArgs.VolumeId == 0 { + return fmt.Errorf("volume id is required") + } + err := downloader.DownloadVolume(volumeArgs.NovelId, volumeArgs.VolumeId, volumeArgs.outputPath) + if err != nil { + return fmt.Errorf("failed to download volume: %v", err) + } + + return nil +} diff --git a/cmd/package.go b/cmd/package.go new file mode 100644 index 0000000..c57a800 --- /dev/null +++ b/cmd/package.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "bilinovel-downloader/utils" + "fmt" + + "github.com/spf13/cobra" +) + +type packArgs struct { + DirPath string `validate:"required"` +} + +var ( + pArgs packArgs +) + +var packCmd = &cobra.Command{ + Use: "pack", + Short: "pack a epub file from directory", + Long: "pack a epub file from directory", + RunE: runPackage, +} + +func init() { + packCmd.Flags().StringVarP(&pArgs.DirPath, "dir-path", "d", "", "directory path") + RootCmd.AddCommand(packCmd) +} + +func runPackage(cmd *cobra.Command, args []string) error { + err := utils.CreateEpub(pArgs.DirPath) + if err != nil { + return fmt.Errorf("failed to create epub: %v", err) + } + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..ebef5cc --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,7 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var RootCmd = &cobra.Command{} diff --git a/downloader/bilinovel.go b/downloader/bilinovel.go new file mode 100644 index 0000000..caef9ea --- /dev/null +++ b/downloader/bilinovel.go @@ -0,0 +1,616 @@ +package downloader + +import ( + "bilinovel-downloader/model" + "bilinovel-downloader/template" + "bilinovel-downloader/utils" + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/google/uuid" +) + +func GetNovel(novelId int) (*model.Novel, error) { + novelUrl := fmt.Sprintf("https://www.bilinovel.com/novel/%v.html", novelId) + resp, err := utils.Request().Get(novelUrl) + if err != nil { + return nil, fmt.Errorf("failed to get novel info: %v", err) + } + if resp.StatusCode() != http.StatusOK { + return nil, fmt.Errorf("failed to get novel info: %v", resp.Status()) + } + + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(resp.Body())) + if err != nil { + return nil, fmt.Errorf("failed to parse html: %v", err) + } + + novel := &model.Novel{} + + novel.Title = strings.TrimSpace(doc.Find(".book-title").First().Text()) + novel.Description = strings.TrimSpace(doc.Find(".book-summary>content").First().Text()) + + doc.Find(".authorname>a").Each(func(i int, s *goquery.Selection) { + novel.Authors = append(novel.Authors, strings.TrimSpace(s.Text())) + }) + doc.Find(".illname>a").Each(func(i int, s *goquery.Selection) { + novel.Authors = append(novel.Authors, strings.TrimSpace(s.Text())) + }) + + volumes, err := GetNovelVolumes(novelId) + if err != nil { + return nil, fmt.Errorf("failed to get novel volumes: %v", err) + } + novel.Volumes = volumes + + return novel, nil +} + +func GetVolume(novelId int, volumeId int) (*model.Volume, error) { + novelUrl := fmt.Sprintf("https://www.bilinovel.com/novel/%v/vol_%v.html", novelId, volumeId) + resp, err := utils.Request().Get(novelUrl) + if err != nil { + return nil, fmt.Errorf("failed to get novel info: %v", err) + } + if resp.StatusCode() != http.StatusOK { + return nil, fmt.Errorf("failed to get novel info: %v", resp.Status()) + } + + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(resp.Body())) + if err != nil { + return nil, fmt.Errorf("failed to parse html: %v", err) + } + + volume := &model.Volume{} + volume.Title = strings.TrimSpace(doc.Find(".book-title").First().Text()) + volume.Description = strings.TrimSpace(doc.Find(".book-summary>content").First().Text()) + volume.Cover = doc.Find(".book-cover").First().AttrOr("src", "") + volume.Url = novelUrl + volume.Chapters = make([]*model.Chapter, 0) + + doc.Find(".authorname>a").Each(func(i int, s *goquery.Selection) { + volume.Authors = append(volume.Authors, strings.TrimSpace(s.Text())) + }) + doc.Find(".illname>a").Each(func(i int, s *goquery.Selection) { + volume.Authors = append(volume.Authors, strings.TrimSpace(s.Text())) + }) + + doc.Find(".chapter-li.jsChapter").Each(func(i int, s *goquery.Selection) { + volume.Chapters = append(volume.Chapters, &model.Chapter{ + Title: s.Find("a").Text(), + Url: fmt.Sprintf("https://www.bilinovel.com%v", s.Find("a").AttrOr("href", "")), + }) + }) + + return volume, nil +} + +func GetNovelVolumes(novelId int) ([]*model.Volume, error) { + catelogUrl := fmt.Sprintf("https://www.bilinovel.com/novel/%v/catalog", novelId) + resp, err := utils.Request().Get(catelogUrl) + if err != nil { + return nil, fmt.Errorf("failed to get catelog: %v", err) + } + if resp.StatusCode() != http.StatusOK { + return nil, fmt.Errorf("failed to get catelog: %v", resp.Status()) + } + + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(resp.Body())) + if err != nil { + return nil, fmt.Errorf("failed to parse html: %v", err) + } + + volumeRegexp := regexp.MustCompile(fmt.Sprintf(`/novel/%v/vol_(\d+).html`, novelId)) + + volumeIds := make([]string, 0) + doc.Find("a.volume-cover-img").Each(func(i int, s *goquery.Selection) { + link := s.AttrOr("href", "") + matches := volumeRegexp.FindStringSubmatch(link) + if len(matches) > 0 { + volumeIds = append(volumeIds, matches[1]) + } + }) + + volumes := make([]*model.Volume, 0) + for _, volumeIdStr := range volumeIds { + volumeId, err := strconv.Atoi(volumeIdStr) + if err != nil { + return nil, fmt.Errorf("failed to convert volume id: %v", err) + } + volume, err := GetVolume(novelId, volumeId) + if err != nil { + return nil, fmt.Errorf("failed to get volume info: %v", err) + } + volumes = append(volumes, volume) + } + + return volumes, nil +} + +func DownloadNovel(novelId int, outputPath string) error { + log.Printf("Downloading Novel: %v", novelId) + + novel, err := GetNovel(novelId) + if err != nil { + return fmt.Errorf("failed to get novel info: %v", err) + } + + outputPath = filepath.Join(outputPath, utils.CleanDirName(novel.Title)) + err = os.MkdirAll(outputPath, 0755) + if err != nil { + return fmt.Errorf("failed to create output directory: %v", err) + } + + for _, volume := range novel.Volumes { + err := downloadVolume(volume, outputPath) + if err != nil { + return fmt.Errorf("failed to download volume: %v", err) + } + } + + return nil +} + +func DownloadVolume(novelId, volumeId int, outputPath string) error { + volume, err := GetVolume(novelId, volumeId) + if err != nil { + return fmt.Errorf("failed to get volume info: %v", err) + } + err = downloadVolume(volume, outputPath) + if err != nil { + return fmt.Errorf("failed to download volume: %v", err) + } + return nil +} + +func downloadVolume(volume *model.Volume, outputPath string) error { + log.Printf("Downloading Volume: %s", volume.Title) + outputPath = filepath.Join(outputPath, utils.CleanDirName(volume.Title)) + err := os.MkdirAll(outputPath, 0755) + if err != nil { + return fmt.Errorf("failed to create output directory: %v", err) + } + + _, err = os.Stat(filepath.Join(outputPath, "volume.json")) + if os.IsNotExist(err) { + for idx, chapter := range volume.Chapters { + err := DownloadChapter(idx, chapter, outputPath) + if err != nil { + return fmt.Errorf("failed to download chapter: %v", err) + } + } + } else { + jsonBytes, err := os.ReadFile(filepath.Join(outputPath, "volume.json")) + if err != nil { + return fmt.Errorf("failed to read volume: %v", err) + } + err = json.Unmarshal(jsonBytes, volume) + if err != nil { + return fmt.Errorf("failed to unmarshal volume: %v", err) + } + for idx, chapter := range volume.Chapters { + file, err := os.Create(filepath.Join(outputPath, fmt.Sprintf("OEBPS/Text/chapter-%03v.xhtml", idx+1))) + if err != nil { + return fmt.Errorf("failed to create chapter file: %v", err) + } + err = template.ContentXHTML(chapter).Render(context.Background(), file) + if err != nil { + return fmt.Errorf("failed to render text file: %v", err) + } + } + } + + for i := range volume.Chapters { + volume.Chapters[i].ImageFullPaths = utils.Unique(volume.Chapters[i].ImageFullPaths) + volume.Chapters[i].ImageOEBPSPaths = utils.Unique(volume.Chapters[i].ImageOEBPSPaths) + } + + jsonBytes, err := json.Marshal(volume) + if err != nil { + return fmt.Errorf("failed to marshal volume: %v", err) + } + err = os.WriteFile(filepath.Join(outputPath, "volume.json"), jsonBytes, 0644) + if err != nil { + return fmt.Errorf("failed to write volume: %v", err) + } + + coverPath := filepath.Join(outputPath, "OEBPS/Images/cover.jpg") + err = os.MkdirAll(path.Dir(coverPath), 0755) + if err != nil { + return fmt.Errorf("failed to create cover directory: %v", err) + } + err = DownloadImg(volume.Cover, coverPath) + if err != nil { + return fmt.Errorf("failed to download cover: %v", err) + } + + coverXHTMLPath := filepath.Join(outputPath, "OEBPS/Text/cover.xhtml") + err = os.MkdirAll(path.Dir(coverXHTMLPath), 0755) + if err != nil { + return fmt.Errorf("failed to create cover directory: %v", err) + } + file, err := os.Create(coverXHTMLPath) + if err != nil { + return fmt.Errorf("failed to create cover file: %v", err) + } + err = template.ContentXHTML(&model.Chapter{ + Title: "封面", + Content: fmt.Sprintf(``, path.Ext(volume.Cover)), + }).Render(context.Background(), file) + if err != nil { + return fmt.Errorf("failed to render cover: %v", err) + } + + contentsXHTMLPath := filepath.Join(outputPath, "OEBPS/Text/contents.xhtml") + err = os.MkdirAll(path.Dir(contentsXHTMLPath), 0755) + if err != nil { + return fmt.Errorf("failed to create contents directory: %v", err) + } + file, err = os.Create(contentsXHTMLPath) + if err != nil { + return fmt.Errorf("failed to create contents file: %v", err) + } + contents := strings.Builder{} + contents.WriteString(``) + err = template.ContentXHTML(&model.Chapter{ + Title: "目录", + Content: contents.String(), + }).Render(context.Background(), file) + if err != nil { + return fmt.Errorf("failed to render contents: %v", err) + } + + err = CreateContainerXML(outputPath) + if err != nil { + return fmt.Errorf("failed to create container xml: %v", err) + } + + u, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("failed to generate uuid: %v", err) + } + + err = CreateContentOPF(outputPath, u.String(), volume) + if err != nil { + return fmt.Errorf("failed to create content opf: %v", err) + } + + err = CreateTocNCX(outputPath, u.String(), volume) + if err != nil { + return fmt.Errorf("failed to create toc ncx: %v", err) + } + + err = utils.CreateEpub(outputPath) + if err != nil { + return fmt.Errorf("failed to create epub: %v", err) + } + + return nil +} + +func DownloadChapter(chapterIdx int, chapter *model.Chapter, outputPath string) error { + chapter.TextFullPath = filepath.Join(outputPath, fmt.Sprintf("OEBPS/Text/chapter-%03v.xhtml", chapterIdx+1)) + chapter.TextOEBPSPath = fmt.Sprintf("Text/chapter-%03v.xhtml", chapterIdx+1) + err := os.MkdirAll(path.Dir(chapter.TextFullPath), 0755) + if err != nil { + return fmt.Errorf("failed to create text directory: %v", err) + } + + page := 1 + for { + hasNext, err := downloadChapterByPage(page, chapterIdx, chapter, outputPath) + if err != nil { + return fmt.Errorf("failed to download chapter: %v", err) + } + if !hasNext { + break + } + page++ + time.Sleep(time.Second) + } + + file, err := os.Create(chapter.TextFullPath) + if err != nil { + return fmt.Errorf("failed to create text file: %v", err) + } + + err = template.ContentXHTML(chapter).Render(context.Background(), file) + if err != nil { + return fmt.Errorf("failed to render text file: %v", err) + } + + return nil +} + +func downloadChapterByPage(page, chapterIdx int, chapter *model.Chapter, outputPath string) (bool, error) { + Url := strings.TrimSuffix(chapter.Url, ".html") + fmt.Sprintf("_%v.html", page) + log.Printf("Downloading Chapter: %s", Url) + + hasNext := false + headers := map[string]string{ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "Accept-Language": "zh-CN,zh;q=0.9,en-GB;q=0.8,en;q=0.7,zh-TW;q=0.6", + "Cookie": "night=1;", + } + resp, err := utils.Request().SetHeaders(headers).Get(Url) + if err != nil { + return hasNext, err + } + if resp.StatusCode() != http.StatusOK { + return hasNext, fmt.Errorf("failed to get chapter: %v", resp.Status()) + } + + if strings.Contains(resp.String(), `下一頁`) { + hasNext = true + } + + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(resp.Body())) + if err != nil { + fmt.Println(err) + return hasNext, err + } + + imgSavePath := fmt.Sprintf("OEBPS/Images/chapter-%03v", chapterIdx+1) + + content := doc.Find("#acontent").First() + content.Find(".cgo").Remove() + content.Find("center").Remove() + content.Find(".google-auto-placed").Remove() + + content.Find("img").Each(func(i int, s *goquery.Selection) { + if err != nil { + return + } + imgUrl := s.AttrOr("data-src", "") + if imgUrl == "" { + imgUrl = s.AttrOr("src", "") + if imgUrl == "" { + return + } + } + + fileName := filepath.Join(imgSavePath, fmt.Sprintf("%03v%s", i+1, path.Ext(imgUrl))) + err = DownloadImg(imgUrl, filepath.Join(outputPath, fileName)) + if err == nil { + s.SetAttr("src", "../"+strings.TrimPrefix(fileName, "OEBPS/")) + s.RemoveAttr("class") + s.RemoveAttr("data-src") + if s.AttrOr("alt", "") == "" { + s.SetAttr("alt", fmt.Sprintf("image-%03d", i+1)) + } + chapter.ImageFullPaths = append(chapter.ImageFullPaths, filepath.Join(outputPath, fileName)) + chapter.ImageOEBPSPaths = append(chapter.ImageOEBPSPaths, strings.TrimPrefix(fileName, "OEBPS/")) + } + }) + if err != nil { + return false, fmt.Errorf("failed to download img: %v", err) + } + + html, err := content.Html() + if err != nil { + return false, fmt.Errorf("failed to get html: %v", err) + } + + chapter.Content += strings.TrimSpace(html) + + return hasNext, nil +} + +func DownloadImg(url string, fileName string) error { + _, err := os.Stat(fileName) + if !os.IsNotExist(err) { + return nil + } + + log.Printf("Downloading Image: %s", url) + dir := filepath.Dir(fileName) + err = os.MkdirAll(dir, 0755) + if err != nil { + return err + } + + resp, err := utils.Request().SetHeader("Referer", "https://www.bilinovel.com").Get(url) + if err != nil { + return err + } + + err = os.WriteFile(fileName, resp.Body(), 0644) + if err != nil { + return err + } + + return nil +} + +func CreateContainerXML(dirPath string) error { + containerPath := filepath.Join(dirPath, "META-INF/container.xml") + err := os.MkdirAll(path.Dir(containerPath), 0755) + if err != nil { + return fmt.Errorf("failed to create container directory: %v", err) + } + file, err := os.Create(containerPath) + if err != nil { + return fmt.Errorf("failed to create container file: %v", err) + } + err = template.ContainerXML().Render(context.Background(), file) + if err != nil { + return fmt.Errorf("failed to render container: %v", err) + } + return nil +} + +func CreateContentOPF(dirPath string, uuid string, volume *model.Volume) error { + creators := make([]model.DCCreator, 0) + for _, author := range volume.Authors { + creators = append(creators, model.DCCreator{ + Value: author, + }) + } + dc := &model.DublinCoreMetadata{ + Titles: []model.DCTitle{ + { + Value: volume.Title, + }, + }, + Identifiers: []model.DCIdentifier{ + { + Value: fmt.Sprintf("urn:uuid:%s", uuid), + ID: "book-id", + // Scheme: "UUID", + }, + }, + Languages: []model.DCLanguage{ + { + Value: "zh-TW", + }, + }, + Descriptions: []model.DCDescription{ + { + Value: volume.Description, + }, + }, + Creators: creators, + Metas: []model.DublinCoreMeta{ + { + Name: "cover", + Content: fmt.Sprintf("Images/cover%s", path.Ext(volume.Cover)), + }, + { + Property: "dcterms:modified", + Value: time.Now().UTC().Format("2006-01-02T15:04:05Z"), + }, + }, + } + manifest := &model.Manifest{ + Items: make([]model.ManifestItem, 0), + } + manifest.Items = append(manifest.Items, model.ManifestItem{ + ID: "ncx", + Link: "toc.ncx", + Media: "application/x-dtbncx+xml", + }) + manifest.Items = append(manifest.Items, model.ManifestItem{ + ID: "cover", + Link: "Text/cover.xhtml", + Media: "application/xhtml+xml", + }) + manifest.Items = append(manifest.Items, model.ManifestItem{ + ID: "contents", + Link: "Text/contents.xhtml", + Media: "application/xhtml+xml", + Properties: "nav", + }) + manifest.Items = append(manifest.Items, model.ManifestItem{ + ID: "images-cover", + Link: fmt.Sprintf("Images/cover%s", path.Ext(volume.Cover)), + Media: fmt.Sprintf("image/%s", strings.ReplaceAll(strings.TrimPrefix(path.Ext(volume.Cover), "."), "jpg", "jpeg")), + }) + for _, chapter := range volume.Chapters { + manifest.Items = append(manifest.Items, model.ManifestItem{ + ID: path.Base(chapter.TextOEBPSPath), + Link: chapter.TextOEBPSPath, + Media: "application/xhtml+xml", + }) + for _, image := range chapter.ImageOEBPSPaths { + item := model.ManifestItem{ + ID: strings.Join(strings.Split(strings.ToLower(image), string(filepath.Separator)), "-"), + Link: image, + } + item.Media = fmt.Sprintf("image/%s", strings.ReplaceAll(strings.TrimPrefix(path.Ext(volume.Cover), "."), "jpg", "jpeg")) + manifest.Items = append(manifest.Items, item) + } + } + manifest.Items = append(manifest.Items, model.ManifestItem{ + ID: "style", + Link: "Styles/style.css", + Media: "text/css", + }) + + spine := &model.Spine{ + Items: make([]model.SpineItem, 0), + } + for _, item := range manifest.Items { + if filepath.Ext(item.Link) == ".xhtml" { + spine.Items = append(spine.Items, model.SpineItem{ + IDref: item.ID, + }) + } + } + contentOPFPath := filepath.Join(dirPath, "OEBPS/content.opf") + err := os.MkdirAll(path.Dir(contentOPFPath), 0755) + if err != nil { + return fmt.Errorf("failed to create content directory: %v", err) + } + file, err := os.Create(contentOPFPath) + if err != nil { + return fmt.Errorf("failed to create content file: %v", err) + } + err = template.ContentOPF("book-id", dc, manifest, spine, nil).Render(context.Background(), file) + if err != nil { + return fmt.Errorf("failed to render content: %v", err) + } + return nil +} + +func CreateTocNCX(dirPath string, uuid string, volume *model.Volume) error { + navMap := &model.NavMap{Points: make([]*model.NavPoint, 0)} + navMap.Points = append(navMap.Points, &model.NavPoint{ + Id: "cover", + PlayOrder: 1, + Label: "封面", + Content: model.NavPointContent{Src: "Text/cover.xhtml"}, + }) + navMap.Points = append(navMap.Points, &model.NavPoint{ + Id: "contents", + PlayOrder: 2, + Label: "目录", + Content: model.NavPointContent{Src: "Text/contents.xhtml"}, + }) + for idx, chapter := range volume.Chapters { + navMap.Points = append(navMap.Points, &model.NavPoint{ + Id: fmt.Sprintf("chapter-%03v", idx+1), + PlayOrder: len(navMap.Points) + 1, + Label: chapter.Title, + Content: model.NavPointContent{Src: chapter.TextOEBPSPath}, + }) + } + + head := &model.TocNCXHead{ + Meta: []model.TocNCXHeadMeta{ + {Name: "dtb:uid", Content: fmt.Sprintf("urn:uuid:%s", uuid)}, + }, + } + + ncxPath := filepath.Join(dirPath, "OEBPS/toc.ncx") + err := os.MkdirAll(path.Dir(ncxPath), 0755) + if err != nil { + return fmt.Errorf("failed to create toc directory: %v", err) + } + file, err := os.Create(ncxPath) + if err != nil { + return fmt.Errorf("failed to create toc file: %v", err) + } + err = template.TocNCX(volume.Title, head, navMap).Render(context.Background(), file) + if err != nil { + return fmt.Errorf("failed to render toc: %v", err) + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..837436b --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module bilinovel-downloader + +go 1.24.2 + +require ( + github.com/PuerkitoBio/goquery v1.10.3 + github.com/a-h/templ v0.3.857 + github.com/go-resty/resty/v2 v2.16.5 + github.com/google/uuid v1.6.0 + github.com/spf13/cobra v1.9.1 +) + +require ( + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + golang.org/x/net v0.39.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..84b1afa --- /dev/null +++ b/go.sum @@ -0,0 +1,90 @@ +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/a-h/templ v0.3.857 h1:6EqcJuGZW4OL+2iZ3MD+NnIcG7nGkaQeF2Zq5kf9ZGg= +github.com/a-h/templ v0.3.857/go.mod h1:qhrhAkRFubE7khxLZHsBFHfX+gWwVNKbzKeF9GlPV4M= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= +github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +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/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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-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/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +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/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5080758 --- /dev/null +++ b/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "bilinovel-downloader/cmd" + "log" +) + +func main() { + if err := cmd.RootCmd.Execute(); err != nil { + log.Fatalf("Error executing command: %v", err) + } +} diff --git a/model/container_opf.go b/model/container_opf.go new file mode 100644 index 0000000..c869ddb --- /dev/null +++ b/model/container_opf.go @@ -0,0 +1,190 @@ +package model + +import "encoding/xml" + +type DublinCoreMetadata struct { + XMLName xml.Name `xml:"metadata"` + + // 必需元素 + Titles []DCTitle `xml:"dc:title"` + Identifiers []DCIdentifier `xml:"dc:identifier"` + Languages []DCLanguage `xml:"dc:language"` + + // 可选元素 + Contributors []DCContributor `xml:"dc:contributor"` + Coverages []DCCoverage `xml:"dc:coverage"` + Creators []DCCreator `xml:"dc:creator"` + Dates []DCDate `xml:"dc:date"` + Descriptions []DCDescription `xml:"dc:description"` + Formats []DCFormat `xml:"dc:format"` + Publishers []DCPublisher `xml:"dc:publisher"` + Relations []DCRelation `xml:"dc:relation"` + Rights []DCRights `xml:"dc:rights"` + Subjects []DCSubject `xml:"dc:subject"` + Types []DCType `xml:"dc:type"` + + // EPUB3 扩展的 元素 + Metas []DublinCoreMeta `xml:"meta"` // 用于扩展元数据 +} + +func (d *DublinCoreMetadata) Marshal() (string, error) { + xmlBytes, err := xml.Marshal(d) + if err != nil { + return "", err + } + return string(xmlBytes), nil +} + +// DCTitle 表示 +type DCTitle struct { + Value string `xml:",chardata"` // 标题内容 + ID string `xml:"id,attr,omitempty"` // 标题的唯一 ID + Lang string `xml:"xml:lang,attr,omitempty"` // 语言 +} + +// DCIdentifier 表示 +type DCIdentifier struct { + Value string `xml:",chardata"` // 标识符内容(如 UUID、ISBN) + ID string `xml:"id,attr,omitempty"` // 标识符的唯一 ID + Scheme string `xml:"opf:scheme,attr,omitempty"` // 标识符的方案(如 "uuid") +} + +// DCLanguage 表示 +type DCLanguage struct { + Value string `xml:",chardata"` // 语言代码(如 "en"、"zh") +} + +// DCContributor 表示 +type DCContributor struct { + Value string `xml:",chardata"` // 贡献者名称 + ID string `xml:"id,attr,omitempty"` // 唯一 ID + Role string `xml:"opf:role,attr,omitempty"` // 角色(如 "edt"、"ill") + FileAs string `xml:"opf:file-as,attr,omitempty"` // 规范化名称 + Lang string `xml:"xml:lang,attr,omitempty"` // 语言 +} + +// DCCoverage 表示 +type DCCoverage struct { + Value string `xml:",chardata"` // 地理或时间范围 + Lang string `xml:"xml:lang,attr,omitempty"` // 语言 +} + +// DCCreator 表示 +type DCCreator struct { + Value string `xml:",chardata"` // 创作者名称 + ID string `xml:"id,attr,omitempty"` // 唯一 ID + Role string `xml:"opf:role,attr,omitempty"` // 角色(如 "aut") + FileAs string `xml:"opf:file-as,attr,omitempty"` // 规范化名称 + Lang string `xml:"xml:lang,attr,omitempty"` // 语言 +} + +// DCDate 表示 +type DCDate struct { + Value string `xml:",chardata"` // 日期(如 "2023-01-01") + Event string `xml:"opf:event,attr,omitempty"` // 事件类型(如 "publication") +} + +// DCDescription 表示 +type DCDescription struct { + Value string `xml:",chardata"` // 描述内容 + Lang string `xml:"xml:lang,attr,omitempty"` // 语言 +} + +// DCFormat 表示 +type DCFormat struct { + Value string `xml:",chardata"` // 格式(如 "EPUB 3.0") +} + +// DCPublisher 表示 +type DCPublisher struct { + Value string `xml:",chardata"` // 出版者名称 + Lang string `xml:"xml:lang,attr,omitempty"` // 语言 +} + +// DCRelation 表示 +type DCRelation struct { + Value string `xml:",chardata"` // 相关资源标识符 +} + +// DCRights 表示 +type DCRights struct { + Value string `xml:",chardata"` // 版权信息 + Lang string `xml:"xml:lang,attr,omitempty"` // 语言 +} + +// DCSubject 表示 +type DCSubject struct { + Value string `xml:",chardata"` // 主题或关键词 + Lang string `xml:"xml:lang,attr,omitempty"` // 语言 +} + +// DCType 表示 +type DCType struct { + Value string `xml:",chardata"` // 内容类型(如 "Text"、"Fiction") +} + +// DublinCoreMeta 表示 EPUB3 的 扩展 +type DublinCoreMeta struct { + Name string `xml:"name,attr,omitempty"` + Content string `xml:"content,attr,omitempty"` + Value string `xml:",chardata"` + Property string `xml:"property,attr,omitempty"` +} + +type Manifest struct { + XMLName xml.Name `xml:"manifest"` + Items []ManifestItem `xml:"item"` +} + +func (m *Manifest) Marshal() (string, error) { + xmlBytes, err := xml.Marshal(m) + if err != nil { + return "", err + } + return string(xmlBytes), nil +} + +type ManifestItem struct { + ID string `xml:"id,attr"` + Link string `xml:"href,attr"` + Media string `xml:"media-type,attr,omitempty"` + Properties string `xml:"properties,attr,omitempty"` +} + +type Spine struct { + XMLName xml.Name `xml:"spine"` + Toc string `xml:"toc,attr,omitempty"` + Items []SpineItem `xml:"itemref"` +} + +func (s *Spine) Marshal() (string, error) { + s.Toc = "ncx" + xmlBytes, err := xml.Marshal(s) + if err != nil { + return "", err + } + return string(xmlBytes), nil +} + +type SpineItem struct { + IDref string `xml:"idref,attr"` +} + +type Guide struct { + XMLName xml.Name `xml:"guide"` + Items []GuideItem `xml:"reference"` +} + +func (g *Guide) Marshal() (string, error) { + xmlBytes, err := xml.Marshal(g) + if err != nil { + return "", err + } + return string(xmlBytes), nil +} + +type GuideItem struct { + Title string `xml:"title,attr"` + Type string `xml:"type,attr"` + Link string `xml:"href,attr"` +} diff --git a/model/struct.go b/model/struct.go new file mode 100644 index 0000000..490f720 --- /dev/null +++ b/model/struct.go @@ -0,0 +1,27 @@ +package model + +type Chapter struct { + Title string + Url string + Content string + ImageOEBPSPaths []string + ImageFullPaths []string + TextOEBPSPath string + TextFullPath string +} + +type Volume struct { + Title string + Url string + Cover string + Description string + Authors []string + Chapters []*Chapter +} + +type Novel struct { + Title string + Description string + Authors []string + Volumes []*Volume +} diff --git a/model/toc_ncx.go b/model/toc_ncx.go new file mode 100644 index 0000000..afa5512 --- /dev/null +++ b/model/toc_ncx.go @@ -0,0 +1,47 @@ +package model + +import "encoding/xml" + +type TocNCXHead struct { + XMLName xml.Name `xml:"head"` + Meta []TocNCXHeadMeta `xml:"meta"` +} + +type TocNCXHeadMeta struct { + XMLName xml.Name `xml:"meta"` + Content string `xml:"content,attr"` + Name string `xml:"name,attr"` +} + +func (h *TocNCXHead) Marshal() (string, error) { + xmlBytes, err := xml.Marshal(h) + if err != nil { + return "", err + } + return string(xmlBytes), nil +} + +type NavPoint struct { + Id string `xml:"id,attr"` + PlayOrder int `xml:"playOrder,attr"` + Label string `xml:"navLabel>text"` + Content NavPointContent `xml:"content"` + NavPoints []*NavPoint `xml:"navPoint"` +} + +type NavPointContent struct { + Src string `xml:"src,attr"` +} + +type NavMap struct { + XMLName xml.Name `xml:"navMap"` + Points []*NavPoint `xml:"navPoint"` +} + +func (n *NavMap) Marshal() (string, error) { + xmlBytes, err := xml.Marshal(n) + if err != nil { + return "", err + } + return string(xmlBytes), nil +} diff --git a/template/container.xml.templ b/template/container.xml.templ new file mode 100644 index 0000000..3c80b42 --- /dev/null +++ b/template/container.xml.templ @@ -0,0 +1,10 @@ +package template + +templ ContainerXML() { + @templ.Raw(``) + + + + + +} diff --git a/template/container.xml_templ.go b/template/container.xml_templ.go new file mode 100644 index 0000000..5e35d57 --- /dev/null +++ b/template/container.xml_templ.go @@ -0,0 +1,44 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.857 +package template + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func ContainerXML() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templ.Raw(``).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/template/content.opf.templ b/template/content.opf.templ new file mode 100644 index 0000000..06d95c6 --- /dev/null +++ b/template/content.opf.templ @@ -0,0 +1,33 @@ +package template + +import "bilinovel-downloader/model" + +templ ContentOPF(uniqueIdentifier string, dc *model.DublinCoreMetadata, manifest *model.Manifest, spine *model.Spine, guide *model.Guide) { + @templ.Raw(``) + + if dc != nil { + {{ metadata, err := dc.Marshal() }} + if err == nil { + @templ.Raw(metadata) + } + } + if manifest != nil { + {{ manifest, err := manifest.Marshal() }} + if err == nil { + @templ.Raw(manifest) + } + } + if spine != nil { + {{ spine, err := spine.Marshal() }} + if err == nil { + @templ.Raw(spine) + } + } + if guide != nil { + {{ guide, err := guide.Marshal() }} + if err == nil { + @templ.Raw(guide) + } + } + +} diff --git a/template/content.opf_templ.go b/template/content.opf_templ.go new file mode 100644 index 0000000..72dd8e0 --- /dev/null +++ b/template/content.opf_templ.go @@ -0,0 +1,99 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.857 +package template + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "bilinovel-downloader/model" + +func ContentOPF(uniqueIdentifier string, dc *model.DublinCoreMetadata, manifest *model.Manifest, spine *model.Spine, guide *model.Guide) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templ.Raw(``).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if dc != nil { + metadata, err := dc.Marshal() + if err == nil { + templ_7745c5c3_Err = templ.Raw(metadata).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + if manifest != nil { + manifest, err := manifest.Marshal() + if err == nil { + templ_7745c5c3_Err = templ.Raw(manifest).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + if spine != nil { + spine, err := spine.Marshal() + if err == nil { + templ_7745c5c3_Err = templ.Raw(spine).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + if guide != nil { + guide, err := guide.Marshal() + if err == nil { + templ_7745c5c3_Err = templ.Raw(guide).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/template/content.xhtml.templ b/template/content.xhtml.templ new file mode 100644 index 0000000..60f0efe --- /dev/null +++ b/template/content.xhtml.templ @@ -0,0 +1,23 @@ +package template + +import "bilinovel-downloader/model" + +templ ContentXHTML(content *model.Chapter) { + @templ.Raw(``) + @templ.Raw(``) + + + { content.Title } + @templ.Raw(``) + + +
+

{ content.Title }

+ @templ.Raw(`
`) +
+ @templ.Raw(content.Content) +
+
+ + +} diff --git a/template/content.xhtml_templ.go b/template/content.xhtml_templ.go new file mode 100644 index 0000000..484eabc --- /dev/null +++ b/template/content.xhtml_templ.go @@ -0,0 +1,100 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.857 +package template + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "bilinovel-downloader/model" + +func ContentXHTML(content *model.Chapter) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templ.Raw(``).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(``).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(content.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `template/content.xhtml.templ`, Line: 10, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(``).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(content.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `template/content.xhtml.templ`, Line: 15, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(`
`).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(content.Content).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/template/style.css.go b/template/style.css.go new file mode 100644 index 0000000..e2c5cbf --- /dev/null +++ b/template/style.css.go @@ -0,0 +1,43 @@ +package template + +const StyleCSS = ` +body > div { + margin: 0 auto; + padding: 20px; + box-sizing: border-box; + background-color: #fff; + line-height: 1.6; + text-align: justify; + color: #333333; +} + +h1 { + text-align: center; + font-size: 1.5em; + margin: 2em auto; + font-weight: bold; + color: #2c3e50; +} + +p { + text-indent: 2em; + margin: 0.8em 0; + font-size: 1.1em; +} + +hr { + border: none; + border-bottom: 1px solid #e0e0e0; + margin: 1.5em 20%; +} + +img { + max-width: 80%; + height: auto; + display: block; + margin-left: auto !important; + margin-right: auto !important; + margin-top: 1em; + margin-bottom: 1em; +} +` diff --git a/template/toc.ncx.templ b/template/toc.ncx.templ new file mode 100644 index 0000000..d627ca6 --- /dev/null +++ b/template/toc.ncx.templ @@ -0,0 +1,25 @@ +package template + +import "bilinovel-downloader/model" + +templ TocNCX(title string, head *model.TocNCXHead, navMap *model.NavMap) { + @templ.Raw(``) + @templ.Raw(``) + + if head != nil { + {{ head, err := head.Marshal() }} + if err == nil { + @templ.Raw(head) + } + } + + { title } + + if navMap != nil { + {{ navMap, err := navMap.Marshal() }} + if err == nil { + @templ.Raw(navMap) + } + } + +} diff --git a/template/toc.ncx_templ.go b/template/toc.ncx_templ.go new file mode 100644 index 0000000..09a4017 --- /dev/null +++ b/template/toc.ncx_templ.go @@ -0,0 +1,89 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.857 +package template + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "bilinovel-downloader/model" + +func TocNCX(title string, head *model.TocNCXHead, navMap *model.NavMap) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templ.Raw(``).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(``).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if head != nil { + head, err := head.Marshal() + if err == nil { + templ_7745c5c3_Err = templ.Raw(head).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `template/toc.ncx.templ`, Line: 16, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if navMap != nil { + navMap, err := navMap.Marshal() + if err == nil { + templ_7745c5c3_Err = templ.Raw(navMap).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/utils/clean.go b/utils/clean.go new file mode 100644 index 0000000..e8eb2ad --- /dev/null +++ b/utils/clean.go @@ -0,0 +1,15 @@ +package utils + +import ( + "regexp" + "strings" +) + +func CleanDirName(input string) string { + re := regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F]`) + cleaned := re.ReplaceAllString(input, "_") + + cleaned = strings.TrimSpace(cleaned) + + return cleaned +} diff --git a/utils/epub.go b/utils/epub.go new file mode 100644 index 0000000..8f480b1 --- /dev/null +++ b/utils/epub.go @@ -0,0 +1,117 @@ +package utils + +import ( + "archive/zip" + "bilinovel-downloader/template" + "io" + "os" + "path/filepath" +) + +func CreateEpub(path string) error { + savePath := path + ".epub" + zipFile, err := os.Create(savePath) + if err != nil { + return err + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + err = addStringToZip(zipWriter, "mimetype", "application/epub+zip", zip.Store) + if err != nil { + return err + } + + err = addDirContentToZip(zipWriter, path, zip.Deflate) + if err != nil { + return err + } + + err = addStringToZip(zipWriter, "OEBPS/Styles/style.css", template.StyleCSS, zip.Deflate) + if err != nil { + return err + } + + return nil +} + +// func addFileToZip(zipWriter *zip.Writer, filename string, relPath string, method uint16) error { +// file, err := os.Open(filename) +// if err != nil { +// return err +// } +// defer file.Close() + +// info, err := file.Stat() +// if err != nil { +// return err +// } + +// header, err := zip.FileInfoHeader(info) +// if err != nil { +// return err +// } +// header.Name = relPath +// header.Method = method + +// writer, err := zipWriter.CreateHeader(header) +// if err != nil { +// return err +// } + +// _, err = io.Copy(writer, file) +// return err +// } + +func addStringToZip(zipWriter *zip.Writer, relPath, content string, method uint16) error { + header := &zip.FileHeader{ + Name: relPath, + Method: method, + } + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + _, err = writer.Write([]byte(content)) + return err +} + +func addDirContentToZip(zipWriter *zip.Writer, dirPath string, method uint16) error { + return filepath.Walk(dirPath, func(filePath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + relPath, err := filepath.Rel(dirPath, filePath) + if err != nil { + return err + } + + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = relPath + header.Method = method + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + _, err = io.Copy(writer, file) + return err + }) +} diff --git a/utils/request.go b/utils/request.go new file mode 100644 index 0000000..69af7ca --- /dev/null +++ b/utils/request.go @@ -0,0 +1,56 @@ +package utils + +import ( + "context" + "net" + "net/http" + "time" + + "github.com/go-resty/resty/v2" +) + +var client *resty.Client + +func init() { + client = resty.New() + client.SetTransport(&http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + if addr == "www.bilinovel.com:443" { + addr = "64.140.161.52:443" + } + return (&net.Dialer{ + Timeout: 10 * time.Second, + }).DialContext(ctx, network, addr) + }, + TLSHandshakeTimeout: 10 * time.Second, + }) + client.SetRetryCount(10). + SetRetryWaitTime(3 * time.Second). + SetRetryAfter(func(client *resty.Client, resp *resty.Response) (time.Duration, error) { + if resp.StatusCode() == http.StatusTooManyRequests { + if retryAfter := resp.Header().Get("Retry-After"); retryAfter != "" { + if seconds, err := time.ParseDuration(retryAfter + "s"); err == nil { + return seconds, nil + } + if t, err := http.ParseTime(retryAfter); err == nil { + return time.Until(t), nil + } + } + return 3 * time.Second, nil + } + return 0, nil + }). + AddRetryCondition(func(r *resty.Response, err error) bool { + return err != nil || r.StatusCode() == http.StatusTooManyRequests + }) +} + +func Request() *resty.Request { + return client.R().SetLogger(disableLogger{}).SetHeader("Accept-Charset", "utf-8").SetHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0") +} + +type disableLogger struct{} + +func (d disableLogger) Errorf(string, ...interface{}) {} +func (d disableLogger) Warnf(string, ...interface{}) {} +func (d disableLogger) Debugf(string, ...interface{}) {} diff --git a/utils/unique.go b/utils/unique.go new file mode 100644 index 0000000..81553b9 --- /dev/null +++ b/utils/unique.go @@ -0,0 +1,15 @@ +package utils + +func Unique[T comparable](slice []T) []T { + seen := make(map[T]struct{}) + var result []T + + for _, v := range slice { + if _, ok := seen[v]; !ok { + seen[v] = struct{}{} + result = append(result, v) + } + } + + return result +}