├── docs
├── 81841388.png
├── 84782349.png
├── README_zh-CN.md
└── README_zh-TW.md
├── pkg
├── epub
│ ├── dirinfo.go
│ ├── fs.go
│ ├── internal
│ │ └── storage
│ │ │ ├── osfs
│ │ │ └── fs.go
│ │ │ ├── memory
│ │ │ ├── file.go
│ │ │ └── fs.go
│ │ │ └── storage.go
│ ├── xhtml.go
│ ├── fetchmedia.go
│ ├── toc.go
│ ├── pkg.go
│ ├── write.go
│ └── epub.go
├── app
│ ├── middleware.go
│ └── app.go
├── config
│ └── struct.go
├── tools
│ └── tools.go
└── progressbar
│ └── progressbar.go
├── LICENSE
├── go.mod
├── .gitignore
├── README.md
├── main.go
└── go.sum
/docs/81841388.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexiaAshford/pineapple-backups/HEAD/docs/81841388.png
--------------------------------------------------------------------------------
/docs/84782349.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexiaAshford/pineapple-backups/HEAD/docs/84782349.png
--------------------------------------------------------------------------------
/pkg/epub/dirinfo.go:
--------------------------------------------------------------------------------
1 | package epub
2 |
3 | import "io/fs"
4 |
5 | // This is a transitioning function for go < 1.17
6 |
7 | // dirInfo is a DirEntry based on a FileInfo.
8 | type dirInfo struct {
9 | fileInfo fs.FileInfo
10 | }
11 |
12 | func (di dirInfo) IsDir() bool {
13 | return di.fileInfo.IsDir()
14 | }
15 |
16 | func (di dirInfo) Type() fs.FileMode {
17 | return di.fileInfo.Mode().Type()
18 | }
19 |
20 | func (di dirInfo) Info() (fs.FileInfo, error) {
21 | return di.fileInfo, nil
22 | }
23 |
24 | func (di dirInfo) Name() string {
25 | return di.fileInfo.Name()
26 | }
27 |
28 | // fileInfoToDirEntry returns a DirEntry that returns information from info.
29 | // If info is nil, FileInfoToDirEntry returns nil.
30 | func fileInfoToDirEntry(info fs.FileInfo) fs.DirEntry {
31 | if info == nil {
32 | return nil
33 | }
34 | return dirInfo{fileInfo: info}
35 | }
36 |
--------------------------------------------------------------------------------
/pkg/epub/fs.go:
--------------------------------------------------------------------------------
1 | package epub
2 |
3 | import (
4 | "github.com/AlexiaVeronica/pineapple-backups/pkg/epub/internal/storage"
5 | "github.com/AlexiaVeronica/pineapple-backups/pkg/epub/internal/storage/memory"
6 | "github.com/AlexiaVeronica/pineapple-backups/pkg/epub/internal/storage/osfs"
7 | "os"
8 | )
9 |
10 | type FSType int
11 |
12 | // filesystem is the current filesytem used as the underlying layer to manage the files.
13 | // See the storage.Use method to change it.
14 | var filesystem storage.Storage = osfs.NewOSFS(os.TempDir())
15 |
16 | const (
17 | // This defines the local filesystem
18 | OsFS FSType = iota
19 | // This defines the memory filesystem
20 | MemoryFS
21 | )
22 |
23 | // Use s as default storage/ This is typically used in an init function.
24 | // Default to local filesystem
25 | func Use(s FSType) {
26 | switch s {
27 | case OsFS:
28 | filesystem = osfs.NewOSFS(os.TempDir())
29 | case MemoryFS:
30 | //TODO
31 | filesystem = memory.NewMemory()
32 | default:
33 | panic("unexpected FSType")
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Veronica
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/pkg/epub/internal/storage/osfs/fs.go:
--------------------------------------------------------------------------------
1 | // Package osfs implements the Storage interface for os' filesystems
2 |
3 | package osfs
4 |
5 | import (
6 | "io/fs"
7 | "os"
8 | "path/filepath"
9 |
10 | "github.com/AlexiaVeronica/pineapple-backups/pkg/epub/internal/storage"
11 | )
12 |
13 | type OSFS struct {
14 | rootDir string
15 | fs.FS
16 | }
17 |
18 | func NewOSFS(rootDir string) *OSFS {
19 | return &OSFS{
20 | rootDir: rootDir,
21 | FS: os.DirFS(rootDir),
22 | }
23 | }
24 |
25 | func (o *OSFS) WriteFile(name string, data []byte, perm fs.FileMode) error {
26 | return os.WriteFile(filepath.Join(o.rootDir, name), data, perm)
27 | }
28 |
29 | func (o *OSFS) Mkdir(name string, perm fs.FileMode) error {
30 | return os.Mkdir(filepath.Join(o.rootDir, name), perm)
31 | }
32 |
33 | func (o *OSFS) RemoveAll(name string) error {
34 | return os.RemoveAll(filepath.Join(o.rootDir, name))
35 | }
36 |
37 | func (o *OSFS) Create(name string) (storage.File, error) {
38 | return os.Create(filepath.Join(o.rootDir, name))
39 | }
40 |
41 | func (o *OSFS) Stat(name string) (fs.FileInfo, error) {
42 | return os.Stat(filepath.Join(o.rootDir, name))
43 | }
44 |
45 | func (o *OSFS) Open(name string) (fs.File, error) {
46 | return os.Open(filepath.Join(o.rootDir, name))
47 | }
48 |
--------------------------------------------------------------------------------
/pkg/epub/internal/storage/memory/file.go:
--------------------------------------------------------------------------------
1 | package memory
2 |
3 | import (
4 | "io"
5 | "io/fs"
6 | "time"
7 | )
8 |
9 | type file struct {
10 | name string
11 | modTime time.Time
12 | offset int
13 | content []byte
14 | mode fs.FileMode
15 | }
16 |
17 | func (f *file) Info() (fs.FileInfo, error) {
18 | return f, nil
19 | }
20 |
21 | func (f *file) Stat() (fs.FileInfo, error) {
22 | return f, nil
23 | }
24 |
25 | func (f *file) Read(b []byte) (int, error) {
26 | length := len(f.content)
27 | start := f.offset
28 | if start == length {
29 | return 0, io.EOF
30 | }
31 | end := start + len(b)
32 | if end > length {
33 | end = length
34 | }
35 | f.offset = end
36 | count := copy(b, f.content[start:end])
37 | return count, nil
38 | }
39 |
40 | func (f *file) Close() error {
41 | f.offset = 0
42 | return nil
43 | }
44 |
45 | func (f *file) Write(p []byte) (n int, err error) {
46 | f.content = append(f.content, p...)
47 | return len(p), nil
48 | }
49 |
50 | func (f *file) Name() string {
51 | return f.name
52 | }
53 |
54 | func (f *file) Size() int64 {
55 | return int64(len(f.content))
56 | }
57 |
58 | func (f *file) Type() fs.FileMode {
59 | return f.mode & fs.ModeType
60 | }
61 |
62 | func (f *file) Mode() fs.FileMode {
63 | return f.mode
64 | }
65 |
66 | func (f *file) ModTime() time.Time {
67 | return f.modTime
68 | }
69 |
70 | func (f *file) IsDir() bool {
71 | return f.mode&fs.ModeDir != 0
72 | }
73 |
74 | func (f *file) Sys() interface{} {
75 | return nil
76 | }
77 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/AlexiaVeronica/pineapple-backups
2 |
3 | go 1.22.0
4 |
5 | require (
6 | github.com/AlexiaVeronica/boluobaoLib v0.3.2
7 | github.com/AlexiaVeronica/hbookerLib v0.4.2
8 | github.com/AlexiaVeronica/input v0.0.1
9 | github.com/gabriel-vasile/mimetype v1.4.3
10 | github.com/gofrs/uuid v4.4.0+incompatible
11 | github.com/google/uuid v1.4.0
12 | github.com/mattn/go-runewidth v0.0.15
13 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db
14 | github.com/urfave/cli v1.22.14
15 | github.com/vincent-petithory/dataurl v1.0.0
16 | golang.org/x/crypto v0.21.0
17 | )
18 |
19 | require (
20 | github.com/AlexiaVeronica/req/v3 v3.43.5 // indirect
21 | github.com/andybalholm/brotli v1.1.0 // indirect
22 | github.com/cloudflare/circl v1.3.7 // indirect
23 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
24 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
25 | github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7 // indirect
26 | github.com/hashicorp/errwrap v1.1.0 // indirect
27 | github.com/hashicorp/go-multierror v1.1.1 // indirect
28 | github.com/klauspost/compress v1.17.7 // indirect
29 | github.com/onsi/ginkgo/v2 v2.16.0 // indirect
30 | github.com/quic-go/qpack v0.4.0 // indirect
31 | github.com/quic-go/quic-go v0.41.0 // indirect
32 | github.com/refraction-networking/utls v1.6.3 // indirect
33 | github.com/rivo/uniseg v0.2.0 // indirect
34 | github.com/russross/blackfriday/v2 v2.1.0 // indirect
35 | go.uber.org/mock v0.4.0 // indirect
36 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
37 | golang.org/x/mod v0.16.0 // indirect
38 | golang.org/x/net v0.22.0 // indirect
39 | golang.org/x/sys v0.18.0 // indirect
40 | golang.org/x/term v0.18.0 // indirect
41 | golang.org/x/text v0.14.0 // indirect
42 | golang.org/x/tools v0.19.0 // indirect
43 | )
44 |
--------------------------------------------------------------------------------
/pkg/epub/internal/storage/storage.go:
--------------------------------------------------------------------------------
1 | // Package storage hold and abstraction of the filesystem
2 |
3 | package storage
4 |
5 | import (
6 | "io"
7 | "io/fs"
8 | "io/ioutil"
9 | "os"
10 | "path/filepath"
11 | )
12 |
13 | // Storage is an abstraction of the filesystem
14 | type Storage interface {
15 | fs.FS
16 | // WriteFile writes data to the named file, creating it if necessary. If the file does not exist, WriteFile creates it with permissions perm (before umask); otherwise WriteFile truncates it before writing, without changing permissions.
17 | WriteFile(name string, data []byte, perm fs.FileMode) error
18 | // Mkdir creates a new directory with the specified name and permission bits (before umask). If there is an error, it will be of type *PathError.
19 | Mkdir(name string, perm fs.FileMode) error
20 | // RemoveAll removes path and any children it contains. It removes everything it can but returns the first error it encounters. If the path does not exist, RemoveAll returns nil (no error). If there is an error, it will be of type *PathError.
21 | RemoveAll(name string) error
22 | // Create creates or truncates the named file. If the file already exists, it is truncated. If the file does not exist, it is created with mode 0666 (before umask). If successful, methods on the returned File can be used for I/O; the associated file descriptor has mode O_RDWR. If there is an error, it will be of type *PathError.
23 | Create(name string) (File, error)
24 | }
25 |
26 | type File interface {
27 | fs.File
28 | io.Writer
29 | }
30 |
31 | // ReadFile returns the content of name in the filesystem
32 | func ReadFile(fs Storage, name string) ([]byte, error) {
33 | f, err := fs.Open(name)
34 | if err != nil {
35 | return nil, err
36 | }
37 | defer f.Close()
38 | return ioutil.ReadAll(f)
39 | }
40 | func MkdirAll(fs Storage, dir string, perm fs.FileMode) error {
41 | list := make([]string, 0)
42 | stop := ""
43 | for dir := filepath.Dir(dir); dir != stop; dir = filepath.Dir(dir) {
44 | list = append(list, dir)
45 | stop = dir
46 | }
47 | for i := len(list); i > 0; i-- {
48 | err := fs.Mkdir(list[i-1], perm)
49 | if err != nil && !os.IsExist(err) {
50 | return err
51 | }
52 | }
53 | return nil
54 |
55 | }
56 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | ./config.json
6 |
7 | # C extensions
8 | *.so
9 |
10 | # Distribution / packaging
11 | .Python
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | pip-wheel-metadata/
25 | share/python-wheels/
26 | *.egg-info/
27 | .installed.cfg
28 | *.egg
29 | MANIFEST
30 |
31 | # PyInstaller
32 | # Usually these files are written by a python script from a template
33 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
34 | *.manifest
35 | *.spec
36 |
37 | # Installer logs
38 | pip-log.txt
39 | pip-delete-this-directory.txt
40 |
41 | # Unit test / coverage reports
42 | htmlcov/
43 | .tox/
44 | .nox/
45 | .coverage
46 | .coverage.*
47 | .cache
48 | nosetests.xml
49 | coverage.xml
50 | *.cover
51 | *.py,cover
52 | .hypothesis/
53 | .pytest_cache/
54 |
55 | # Translations
56 | *.mo
57 | *.pot
58 |
59 | # Django stuff:
60 | *.log
61 | local_settings.py
62 | db.sqlite3
63 | db.sqlite3-journal
64 |
65 | # Flask stuff:
66 | instance/
67 | .webassets-cache
68 |
69 | # Scrapy stuff:
70 | .scrapy
71 |
72 | # Sphinx documentation
73 | docs/_build/
74 |
75 | # PyBuilder
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | .python-version
87 |
88 | # pipenv
89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
92 | # install all needed dependencies.
93 | #Pipfile.lock
94 |
95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
96 | __pypackages__/
97 |
98 | # Celery stuff
99 | celerybeat-schedule
100 | celerybeat.pid
101 |
102 | # SageMath parsed files
103 | *.sage.py
104 |
105 | # Environments
106 | .env
107 | .venv
108 | env/
109 | venv/
110 | ENV/
111 | env.bak/
112 | venv.bak/
113 |
114 | # Spyder project settings
115 | .spyderproject
116 | .spyproject
117 |
118 | # Rope project settings
119 | .ropeproject
120 |
121 | # mkdocs documentation
122 | /site
123 |
124 | # mypy
125 | .mypy_cache/
126 | .dmypy.json
127 | dmypy.json
128 |
129 | # Pyre type checker
130 | .pyre/
131 |
--------------------------------------------------------------------------------
/pkg/app/middleware.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "path"
7 | "strconv"
8 |
9 | "github.com/AlexiaVeronica/boluobaoLib/boluobaomodel"
10 | "github.com/AlexiaVeronica/hbookerLib/hbookermodel"
11 | )
12 |
13 | func sfContinueFunction(bookInfo *boluobaomodel.BookInfoData, chapter boluobaomodel.ChapterList) bool {
14 | return shouldContinue(BoluobaoLibAPP, strconv.Itoa(chapter.ChapID), chapter.OriginNeedFireMoney == 0)
15 | }
16 |
17 | func sfContentFunction(bookInfo *boluobaomodel.BookInfoData, chapter *boluobaomodel.ContentData) {
18 | fmt.Println(chapter.Title + " 下载完毕")
19 | writeContentToFile(BoluobaoLibAPP, strconv.Itoa(chapter.ChapId), chapter.Title, chapter.Expand.Content)
20 | }
21 | func sfMergeText(file *os.File) func(chapter boluobaomodel.ChapterList) {
22 | return func(chapter boluobaomodel.ChapterList) {
23 | savePath := path.Join("cache", BoluobaoLibAPP, fmt.Sprintf("%v.txt", chapter.ChapID))
24 | if _, err := os.Stat(savePath); err == nil {
25 | content, ok := os.ReadFile(savePath)
26 | if ok == nil {
27 | file.WriteString(fmt.Sprintf("\n\n%s\n%s", chapter.Title, content))
28 | }
29 | }
30 | }
31 | }
32 | func hbookerMergeText(file *os.File) func(chapter hbookermodel.ChapterList) {
33 | return func(chapter hbookermodel.ChapterList) {
34 | savePath := path.Join("cache", BoluobaoLibAPP, fmt.Sprintf("%v.txt", chapter.ChapterID))
35 | if _, err := os.Stat(savePath); err == nil {
36 | content, ok := os.ReadFile(savePath)
37 | if ok == nil {
38 | file.WriteString(fmt.Sprintf("\n\n%s\n%s", chapter.ChapterTitle, content))
39 | }
40 | }
41 | }
42 | }
43 |
44 | func hbookerContinueFunction(bookInfo *hbookermodel.BookInfo, chapter hbookermodel.ChapterList) bool {
45 | return shouldContinue(CiweimaoLibAPP, chapter.ChapterID, chapter.AuthAccess == "1")
46 | }
47 |
48 | func hbookerContentFunction(bookInfo *hbookermodel.BookInfo, chapter *hbookermodel.ChapterInfo) {
49 | writeContentToFile(CiweimaoLibAPP, chapter.ChapterID, chapter.ChapterTitle, chapter.TxtContent)
50 | }
51 |
52 | func shouldContinue(app, chapID string, condition bool) bool {
53 | savePath := path.Join("cache", app, fmt.Sprintf("%v.txt", chapID))
54 | if _, err := os.Stat(savePath); err == nil || (os.IsNotExist(err) && !condition) {
55 | return false
56 | }
57 | return true
58 | }
59 |
60 | func writeContentToFile(app, chapID, title, content string) {
61 | file, err := os.Create(path.Join("cache", app, fmt.Sprintf("%v.txt", chapID)))
62 | if err != nil {
63 | fmt.Printf("writeContentToFile file %s: %v\n", title, err)
64 | return
65 | }
66 | defer file.Close()
67 |
68 | file.WriteString(fmt.Sprintf("\n\n%s\n%s", title, content))
69 | }
70 |
--------------------------------------------------------------------------------
/docs/README_zh-CN.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Noa Himesaka
4 |
5 |
6 | 下载 菠萝包 和
7 | 刺猬猫 的小说到本地阅读.
8 |
9 |
10 |
11 | ## 关于下载sfacg vip书籍
12 |
13 | ---
14 |
15 | - 微信API无法下载vip章节,因为sfacg程序员更新了章节API返回值,新的API无法获取文本,只能获取图片,因此无法下载vip章节。
16 |
17 | - 您需要启用sfacg Android API来实现vip章节下载,您可以修改`main.go`文件中的`App`变量,并将`false`设置为`true`以实现API切换。
18 |
19 |
20 |
21 |
22 |
23 |
24 | ## **功能**
25 |
26 |
27 |
28 | - 菠萝包[`Android`/`WeChat`]刺猬猫安卓接口实现了下载功能
29 |
30 | - 登录您的帐户并将cookie保存到`config.json`
31 |
32 | - 输入图书id或url并将图书下载到本地目录
33 |
34 | - 输入url并从url下载书籍文本
35 |
36 | - 支持从sfacg和hbooker下载epub
37 |
38 | - 按关键字搜索书籍,并下载搜索结果
39 |
40 | - [**警告**]新版本图书缓存与旧版本图书缓存不兼容。
41 |
42 |
43 |
44 |
45 |
46 |
47 | ## 登录您的ciweimao帐户
48 |
49 | - - -
50 |
51 | - 登录您的帐户以获取使用此脚本的“令牌”
52 |
53 | - hboker新版本增加了GEETEST验证,如果您输入错误信息或多次登录,将触发GEETEST校验。
54 |
55 | - IP地址可能需要在几小时后再次登录以避免触发验证,您可以尝试更改IP以避免触发确认。
56 |
57 |
58 |
59 |
60 |
61 |
62 | ## API访问通过令牌实现。
63 |
64 | - - -
65 |
66 | - **采用token访问api,绕过登录**
67 |
68 | - 第三方captcha geetest已添加到ciweimao官方服务器。
69 |
70 | - ciweimao登录受到geetest的保护,这似乎是不可能规避的。
71 |
72 | - 您可以提供`刺猬猫 Android`应用程序的数据包捕获以获取`account`和`login token`登录。
73 |
74 |
75 |
76 |
77 | ## **Example**
78 | - - -
79 | ``` bash
80 | NAME:
81 | pineapple-backups - https://github.com/AlexiaVeronica/pineapple-backups
82 |
83 | USAGE:
84 | main.exe [global options] command [command options] [arguments...]
85 |
86 | VERSION:
87 | V.1.7.0
88 |
89 | COMMANDS:
90 | help, h Shows a list of commands or help for one command
91 |
92 | GLOBAL OPTIONS:
93 | -a value, --app value cheng app type (default: "cat")
94 | -d value, --download value book id
95 | -t, --token input hbooker token
96 | -m value, --max value change max thread number (default: 16)
97 | -u value, --user value input account name
98 | -p value, --password value input password
99 | --update update book
100 | -s value, --search value search book by keyword
101 | -l, --login login local account
102 | -e, --epub start epub
103 | --help, -h show help
104 | --version, -v print the version
105 |
106 | ```
107 |
108 | ## **Disclaimers**
109 |
110 | - This tool is for learning only. Please delete it from your computer within 24 hours after downloading.
111 | - Please respect the copyright and do not spread the crawled books by yourself.
112 | - In no event shall the authors or copyright holders be liable for any claim damages or other liability, whether in an
113 | action of contract tort or otherwise, arising from, out of or in connection with the software or the use or other
114 | dealings in the software , including but not limited to the use of the software for illegal purposes,author is not
115 | responsible for any legal consequences.
116 | - If you have any questions, please contact me by github issues or email.
--------------------------------------------------------------------------------
/docs/README_zh-TW.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Noa Himesaka
4 |
5 |
6 | 下載 菠萝包 和
7 | 刺蝟貓 的小說到本地閱讀.
8 |
9 |
10 | ## **功能**
11 |
12 | - 通過 sfacg wechat Api 和 刺蝟貓 Android Api實現下載功能
13 | - 登錄菠萝包帳戶並將cookies保存到本地檔 ```config.json```
14 | - 輸入圖書id或url,並將圖書下載到本地目錄
15 | - 輸入url,並從url提取書籍id下載書籍文本
16 | - 支援從菠萝包和刺蝟貓下載epub電子書
17 | - 按關鍵字搜索書籍,並下載搜尋結果
18 | - [ **警告** ] 新版本圖書快取與舊版本圖書緩存不相容。
19 |
20 | ## **示例**
21 |
22 | - --app=``````
23 | - --account=``````
24 | - --password=``````
25 | - --download=``````
26 | - --search=```<关键词>```
27 | - --show < 查看 config.json 文件 >
28 | -
29 |
30 | ## **免責聲明**
31 |
32 | - 此工具僅用於學習。 請在下載後24小時內將其從計算機中刪除。
33 | - 請尊重版權,請勿自行傳播爬蟲圖書,在任何情況下,作者或版權持有人均不對任何索賠負責
34 | - 損害賠償或其他責任,無論是在合同訴訟中,因軟體或軟體的使用或其他交易而產生的侵權行為或其他行為,作者均不承擔責任。
35 |
36 |
37 |
38 | ## **文件樹**
39 |
40 | ```
41 | C:.
42 | │ .gitignore
43 | │ config.json
44 | │ go.mod
45 | │ go.sum
46 | │ LICENSE
47 | │ main.go
48 | │ README.md
49 | │
50 | ├─.idea
51 | │ workspace.xml
52 | │
53 | ├─cache
54 | ├─config
55 | │ command.go
56 | │ config.go
57 | │ file.go
58 | │ msg.go
59 | │ thread.go
60 | │ tool.go
61 | │
62 | ├─docs
63 | │ 81841388.png
64 | │ 84782349.png
65 | │ README_zh-CN.md
66 | │ README_zh-TW.md
67 | │
68 | ├─epub
69 | │ │ dirinfo.go
70 | │ │ epub.go
71 | │ │ fetchmedia.go
72 | │ │ fs.go
73 | │ │ pkg.go
74 | │ │ toc.go
75 | │ │ write.go
76 | │ │ xhtml.go
77 | │ │
78 | │ └─internal
79 | │ └─storage
80 | │ │ storage.go
81 | │ │
82 | │ ├─memory
83 | │ │ file.go
84 | │ │ fs.go
85 | │ │
86 | │ └─osfs
87 | │ fs.go
88 | │
89 | ├─save
90 | ├─src
91 | │ │ book.go
92 | │ │ bookshelf.go
93 | │ │ catalogue.go
94 | │ │ login.go
95 | │ │ progressbar.go
96 | │ │ search.go
97 | │ │
98 | │ ├─boluobao
99 | │ │ api.go
100 | │ │
101 | │ ├─hbooker
102 | │ │ │ api.go
103 | │ │ │ Geetest.go
104 | │ │ │ UrlConstants.go
105 | │ │ │
106 | │ │ └─Encrypt
107 | │ │ decode.go
108 | │ │ Encrypt.go
109 | │ │
110 | │ └─https
111 | │ Header.go
112 | │ param.go
113 | │ request.go
114 | │ urlconstant.go
115 | │
116 | └─struct
117 | │ command.go
118 | │ config.go
119 | │
120 | ├─book_info
121 | │ book_info.go
122 | │
123 | ├─hbooker_structs
124 | │ │ chapter.go
125 | │ │ config.go
126 | │ │ content.go
127 | │ │ detail.go
128 | │ │ geetest.go
129 | │ │ key.go
130 | │ │ login.go
131 | │ │ recommend.go
132 | │ │ search.go
133 | │ │
134 | │ ├─bookshelf
135 | │ │ bookshelf.go
136 | │ │
137 | │ └─division
138 | │ division.go
139 | │
140 | └─sfacg_structs
141 | │ account.go
142 | │ book.go
143 | │ catalogue.go
144 | │ content.go
145 | │ login.go
146 | │ search.go
147 | │
148 | └─bookshelf
149 | bookshelf.go
150 |
151 | ```
152 |
--------------------------------------------------------------------------------
/pkg/epub/xhtml.go:
--------------------------------------------------------------------------------
1 | package epub
2 |
3 | import (
4 | "encoding/xml"
5 | "fmt"
6 | )
7 |
8 | const (
9 | xhtmlDoctype = `
10 | `
11 | xhtmlLinkRel = "stylesheet"
12 | xhtmlTemplate = `
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | `
21 | )
22 |
23 | // xhtml implements an XHTML document
24 | type xhtml struct {
25 | xml *xhtmlRoot
26 | }
27 |
28 | // This holds the actual XHTML content
29 | type xhtmlRoot struct {
30 | XMLName xml.Name `xml:"http://www.w3.org/1999/xhtml html"`
31 | XmlnsEpub string `xml:"xmlns:epub,attr,omitempty"`
32 | Head xhtmlHead `xml:"head"`
33 | Body xhtmlInnerxml `xml:"body"`
34 | }
35 |
36 | type xhtmlHead struct {
37 | Title string `xml:"title"`
38 | Link *xhtmlLink
39 | }
40 |
41 | // The element, used to link to stylesheets
42 | // Ex:
43 | type xhtmlLink struct {
44 | XMLName xml.Name `xml:"link,omitempty"`
45 | Rel string `xml:"rel,attr,omitempty"`
46 | Type string `xml:"type,attr,omitempty"`
47 | Href string `xml:"href,attr,omitempty"`
48 | }
49 |
50 | // This holds the content of the XHTML document between the tags. It is
51 | // implemented as a string because we don't know what it will contain and we
52 | // leave it up to the user of the package to validate the content
53 | type xhtmlInnerxml struct {
54 | XML string `xml:",innerxml"`
55 | }
56 |
57 | // Constructor for xhtml
58 | func newXhtml(body string) *xhtml {
59 | x := &xhtml{
60 | xml: newXhtmlRoot(),
61 | }
62 | x.setBody(body)
63 |
64 | return x
65 | }
66 |
67 | // Constructor for xhtmlRoot
68 | func newXhtmlRoot() *xhtmlRoot {
69 | r := &xhtmlRoot{}
70 | err := xml.Unmarshal([]byte(xhtmlTemplate), &r)
71 | if err != nil {
72 | panic(fmt.Sprintf(
73 | "Error unmarshalling xhtmlRoot: %s\n"+
74 | "\txhtmlRoot=%#v\n"+
75 | "\txhtmlTemplate=%s",
76 | err,
77 | *r,
78 | xhtmlTemplate))
79 | }
80 |
81 | return r
82 | }
83 |
84 | func (x *xhtml) setBody(body string) {
85 | x.xml.Body.XML = "\n" + body + "\n"
86 | }
87 |
88 | func (x *xhtml) setCSS(path string) {
89 | x.xml.Head.Link = &xhtmlLink{
90 | Rel: xhtmlLinkRel,
91 | Type: mediaTypeCSS,
92 | Href: path,
93 | }
94 | }
95 |
96 | func (x *xhtml) setTitle(title string) {
97 | x.xml.Head.Title = title
98 | }
99 |
100 | func (x *xhtml) setXmlnsEpub(xmlns string) {
101 | x.xml.XmlnsEpub = xmlns
102 | }
103 |
104 | func (x *xhtml) Title() string {
105 | return x.xml.Head.Title
106 | }
107 |
108 | // Write the XHTML file to the specified path
109 | func (x *xhtml) write(xhtmlFilePath string) {
110 | xhtmlFileContent, err := xml.MarshalIndent(x.xml, "", " ")
111 | if err != nil {
112 | panic(fmt.Sprintf(
113 | "Error marshalling XML for XHTML file: %s\n"+
114 | "\tXML=%#v",
115 | err,
116 | x.xml))
117 | }
118 |
119 | // Add the doctype declaration to the output
120 | xhtmlFileContent = append([]byte(xhtmlDoctype), xhtmlFileContent...)
121 | // Add the xml header to the output
122 | xhtmlFileContent = append([]byte(xml.Header), xhtmlFileContent...)
123 | // It's generally nice to have files end with a newline
124 | xhtmlFileContent = append(xhtmlFileContent, "\n"...)
125 |
126 | if err := filesystem.WriteFile(xhtmlFilePath, []byte(xhtmlFileContent), filePermissions); err != nil {
127 | panic(fmt.Sprintf("Error writing XHTML file: %s", err))
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/pkg/config/struct.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "path"
8 | "sync"
9 |
10 | "github.com/AlexiaVeronica/boluobaoLib"
11 | "github.com/AlexiaVeronica/boluobaoLib/boluobaomodel"
12 | "github.com/AlexiaVeronica/hbookerLib"
13 | "github.com/AlexiaVeronica/hbookerLib/hbookermodel"
14 | "github.com/AlexiaVeronica/pineapple-backups/pkg/tools"
15 | "github.com/google/uuid"
16 | )
17 |
18 | var (
19 | Apps = AppConfig{}
20 | Vars = &Apps.Config
21 | FileLock = &sync.Mutex{}
22 | )
23 |
24 | type AppConfig struct {
25 | Hbooker HbookerCommonParams `json:"common_params"`
26 | Sfacg BoluobaoConfig `json:"sfacg_config"`
27 | Config ScriptConfig `json:"script_config"`
28 | }
29 |
30 | var HelpMessage = []string{"input help to see the command list:",
31 | "input quit to quit",
32 | "input download to download book",
33 | "input search to search book",
34 | "input show to show config",
35 | "input update config to update config by config.json",
36 | "input login to login account",
37 | "input app to change app type",
38 | "input max to change max thread number",
39 | "you can input command like this: download \n\n",
40 | }
41 |
42 | type ScriptConfig struct {
43 | ConfigName string `json:"config_name"`
44 | OutputName string `json:"output_name"`
45 | CoverFile string `json:"cover_file"`
46 | DeviceId string `json:"device_id"`
47 | ThreadNum int `json:"thread_num"`
48 | MaxRetry int `json:"max_retry"`
49 | Epub bool `json:"epub"`
50 | }
51 |
52 | type BoluobaoConfig struct {
53 | Cookie string `json:"cookie"`
54 | }
55 |
56 | type HbookerCommonParams struct {
57 | LoginToken string `json:"login_token"`
58 | Account string `json:"account"`
59 | }
60 |
61 | type Hbooker struct {
62 | Client *hbookerLib.Client
63 | BookInfo *hbookermodel.BookInfo
64 | }
65 |
66 | type SFacg struct {
67 | Client *boluobaoLib.Client
68 | BookInfo *boluobaomodel.BookInfoData
69 | }
70 |
71 | var APP = struct {
72 | Hbooker *Hbooker
73 | SFacg *SFacg
74 | }{}
75 |
76 | func UpdateConfig() {
77 | changeVar := false
78 |
79 | if Vars.MaxRetry <= 0 || Vars.MaxRetry >= 10 {
80 | Vars.MaxRetry = 5
81 | changeVar = true
82 | }
83 |
84 | if Vars.DeviceId == "" {
85 | Vars.DeviceId = uuid.New().String()
86 | changeVar = true
87 | }
88 |
89 | if Vars.ConfigName == "" || Vars.OutputName == "" || Vars.CoverFile == "" {
90 | Vars.ConfigName, Vars.OutputName, Vars.CoverFile = "cache", "save", "cover"
91 | changeVar = true
92 | }
93 |
94 | EnsureDirectoriesExist([]string{Vars.ConfigName, Vars.OutputName, path.Join(Vars.ConfigName, Vars.CoverFile)})
95 |
96 | if changeVar {
97 | SaveConfig()
98 | }
99 | }
100 |
101 | func EnsureDirectoriesExist(dirNames []string) {
102 | for _, dir := range dirNames {
103 | if !DirectoryExists(dir) {
104 | tools.Mkdir(dir)
105 | }
106 | }
107 | }
108 |
109 | func DirectoryExists(dirName string) bool {
110 | _, err := os.Stat(dirName)
111 | return !os.IsNotExist(err)
112 | }
113 |
114 | func ReadConfig(fileName string) ([]byte, error) {
115 | if fileName == "" {
116 | fileName = "./config.json"
117 | }
118 | return os.ReadFile(fileName)
119 | }
120 |
121 | func LoadConfig() {
122 |
123 | data, err := ReadConfig("")
124 | if err != nil {
125 | fmt.Println("ReadConfig:", err)
126 | return
127 | }
128 |
129 | if err := json.Unmarshal(data, &Apps); err != nil {
130 | fmt.Println("Load:", err)
131 | }
132 | }
133 |
134 | func SaveConfig() {
135 | FileLock.Lock()
136 | defer FileLock.Unlock()
137 |
138 | data, err := json.MarshalIndent(Apps, "", " ")
139 | if err != nil {
140 | fmt.Println("SaveConfig:", err)
141 | return
142 | }
143 |
144 | if err := os.WriteFile("config.json", data, 0777); err != nil {
145 | fmt.Println("SaveConfig:", err)
146 | }
147 |
148 | LoadConfig()
149 | }
150 |
--------------------------------------------------------------------------------
/pkg/epub/internal/storage/memory/fs.go:
--------------------------------------------------------------------------------
1 | package memory
2 |
3 | import (
4 | "io/fs"
5 | "path"
6 | "strings"
7 | "time"
8 |
9 | "github.com/AlexiaVeronica/pineapple-backups/pkg/epub/internal/storage"
10 | )
11 |
12 | type Memory struct {
13 | fs map[string]*file
14 | }
15 |
16 | func NewMemory() *Memory {
17 | return &Memory{
18 | fs: map[string]*file{
19 | "/": {
20 | name: path.Base("/"),
21 | modTime: time.Now(),
22 | mode: fs.ModeDir | (0666),
23 | },
24 | },
25 | }
26 | }
27 |
28 | // Open opens the named file.
29 | //
30 | // When Open returns an error, it should be of type *PathError
31 | // with the Op field set to "open", the Path field set to name,
32 | // and the Err field describing the problem.
33 | //
34 | // Open should reject attempts to open names that do not satisfy
35 | // ValidPath(name), returning a *PathError with Err set to
36 | // ErrInvalid or ErrNotExist.
37 | func (m *Memory) Open(name string) (fs.File, error) {
38 | var f fs.File
39 | var ok bool
40 | if f, ok = m.fs[name]; !ok {
41 | return nil, fs.ErrNotExist
42 | }
43 | return f, nil
44 | }
45 |
46 | // WriteFile writes data to the named file, creating it if necessary. If the file does not exist, WriteFile creates it with permissions perm (before umask); otherwise WriteFile truncates it before writing, without changing permissions.
47 | func (m *Memory) WriteFile(name string, data []byte, perm fs.FileMode) error {
48 | if !fs.ValidPath(name) {
49 | return fs.ErrInvalid
50 | }
51 | f := &file{
52 | name: path.Base(name),
53 | modTime: time.Now(),
54 | mode: (perm),
55 | content: data,
56 | }
57 | m.fs[name] = f
58 | return nil
59 | }
60 |
61 | // Mkdir creates a new directory with the specified name and permission bits (before umask). If there is an error, it will be of type *PathError.
62 | func (m *Memory) Mkdir(name string, perm fs.FileMode) error {
63 | if !fs.ValidPath(path.Base(name)) {
64 | return fs.ErrInvalid
65 | }
66 | f := &file{
67 | name: path.Base(name),
68 | modTime: time.Now(),
69 | mode: fs.ModeDir | (perm),
70 | }
71 | m.fs[name] = f
72 | return nil
73 | }
74 |
75 | // RemoveAll removes path and any children it contains. It removes everything it can but returns the first error it encounters. If the path does not exist, RemoveAll returns nil (no error). If there is an error, it will be of type *PathError.
76 | func (m *Memory) RemoveAll(name string) error {
77 | for k := range m.fs {
78 | if strings.HasPrefix(k, name) {
79 | delete(m.fs, k)
80 | }
81 | }
82 | return nil
83 | }
84 |
85 | // Create creates or truncates the named file. If the file already exists, it is truncated. If the file does not exist, it is created with mode 0666 (before umask). If successful, methods on the returned File can be used for I/O; the associated file descriptor has mode O_RDWR. If there is an error, it will be of type *PathError.
86 | func (m *Memory) Create(name string) (storage.File, error) {
87 | if !fs.ValidPath(path.Base(name)) {
88 | return nil, fs.ErrInvalid
89 | }
90 | f := &file{
91 | name: path.Base(name),
92 | modTime: time.Now(),
93 | mode: 0666,
94 | }
95 | m.fs[name] = f
96 | return f, nil
97 | }
98 |
99 | // ReadDir reads the named directory
100 | // and returns a list of directory entries sorted by filename.
101 | func (m *Memory) ReadDir(name string) ([]fs.DirEntry, error) {
102 | output := make([]fs.DirEntry, 0)
103 | for k, v := range m.fs {
104 | if path.Dir(k) == name {
105 | output = append(output, v)
106 | }
107 | }
108 | return output, nil
109 | }
110 |
111 | // Stat returns a FileInfo describing the file.
112 | // If there is an error, it should be of type *PathError.
113 | // This makes Memory compatible with the StatFS interface
114 | func (m *Memory) Stat(name string) (fs.FileInfo, error) {
115 | f, ok := m.fs[name]
116 | if !ok {
117 | return nil, &fs.PathError{
118 | Op: "Stat",
119 | Path: name,
120 | Err: fs.ErrNotExist,
121 | }
122 | }
123 | return f.Stat()
124 | }
125 |
--------------------------------------------------------------------------------
/pkg/app/app.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "fmt"
5 | "github.com/AlexiaVeronica/boluobaoLib/boluobaomodel"
6 | "github.com/AlexiaVeronica/hbookerLib/hbookermodel"
7 | "os"
8 | "path"
9 |
10 | "github.com/AlexiaVeronica/boluobaoLib"
11 | "github.com/AlexiaVeronica/hbookerLib"
12 | )
13 |
14 | type APP struct {
15 | CurrentApp string
16 | BookId string
17 | Boluobao *boluobaoLib.Client
18 | Ciweimao *hbookerLib.Client
19 | }
20 |
21 | const (
22 | BoluobaoLibAPP = "boluobao"
23 | CiweimaoLibAPP = "ciweimao"
24 | )
25 |
26 | func NewApp() *APP {
27 | a := &APP{
28 | CurrentApp: BoluobaoLibAPP,
29 | Boluobao: boluobaoLib.NewClient(),
30 | Ciweimao: hbookerLib.NewClient(),
31 | }
32 | a.initDirectories()
33 | return a
34 | }
35 |
36 | func (a *APP) initDirectories() {
37 | directories := []string{
38 | "cache",
39 | "cache/" + CiweimaoLibAPP,
40 | "cache/" + BoluobaoLibAPP,
41 | "save/",
42 | "cover/",
43 | "save/" + CiweimaoLibAPP,
44 | "save/" + BoluobaoLibAPP,
45 | }
46 | for _, dir := range directories {
47 | createCacheDirectory(dir)
48 | }
49 | }
50 | func mergeTextToFile[T any](bookName string) *os.File {
51 | data := new(T)
52 | var savePath string
53 | switch any(data).(type) {
54 | case *boluobaomodel.ChapterList:
55 | savePath = path.Join("save", BoluobaoLibAPP, fmt.Sprintf("%v.txt", bookName))
56 | case *hbookermodel.ChapterList:
57 | savePath = path.Join("save", CiweimaoLibAPP, fmt.Sprintf("%v.txt", bookName))
58 | }
59 | file, err := os.OpenFile(savePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
60 | if err != nil {
61 | fmt.Printf("Failed to create file %s: %v\n", savePath, err)
62 | return nil
63 | }
64 | return file
65 | }
66 |
67 | func createCacheDirectory(path string) {
68 | if _, err := os.Stat(path); os.IsNotExist(err) {
69 | if err = os.MkdirAll(path, 0777); err != nil {
70 | fmt.Printf("Failed to create directory %s: %v\n", path, err)
71 | }
72 | }
73 | }
74 |
75 | func (a *APP) SetCurrentApp(app string) {
76 | switch app {
77 | case BoluobaoLibAPP, CiweimaoLibAPP:
78 | a.CurrentApp = app
79 | default:
80 | panic("Invalid app type: " + app)
81 | }
82 | }
83 |
84 | func (a *APP) GetCurrentApp() string {
85 | return a.CurrentApp
86 | }
87 |
88 | func (a *APP) SearchDetailed(keyword string) *APP {
89 | switch a.CurrentApp {
90 | case BoluobaoLibAPP:
91 | newApp := a.Boluobao.APP()
92 | newApp.Search(keyword, sfContinueFunction, sfContentFunction).MergeText(
93 | sfMergeText(mergeTextToFile[boluobaomodel.ChapterList](newApp.GetBookInfo().NovelName)))
94 | case CiweimaoLibAPP:
95 | newApp := a.Ciweimao.APP().Search(keyword, hbookerContinueFunction, hbookerContentFunction)
96 | newApp.MergeText(hbookerMergeText(mergeTextToFile[hbookermodel.ChapterList](newApp.GetBookInfo().BookName)))
97 | }
98 | return a
99 | }
100 |
101 | func (a *APP) DownloadBookByBookId(bookId string) *APP {
102 | switch a.CurrentApp {
103 | case BoluobaoLibAPP:
104 | bookInfo, err := a.Boluobao.API().GetBookInfo(bookId)
105 | if err != nil {
106 | fmt.Println("Failed to get book info:", err)
107 | return a
108 | }
109 | a.Boluobao.APP().SetBookInfo(&bookInfo.Data).Download(sfContinueFunction, sfContentFunction).MergeText(
110 | sfMergeText(mergeTextToFile[boluobaomodel.ChapterList](bookInfo.Data.NovelName)))
111 | case CiweimaoLibAPP:
112 | bookInfo, err := a.Ciweimao.API().GetBookInfo(bookId)
113 | if err != nil {
114 | fmt.Println("Failed to get book info:", err)
115 | return a
116 | }
117 | a.Ciweimao.APP().SetBookInfo(&bookInfo.Data.BookInfo).Download(hbookerContinueFunction, hbookerContentFunction).
118 | MergeText(hbookerMergeText(mergeTextToFile[hbookermodel.ChapterList](bookInfo.Data.BookInfo.BookName)))
119 | }
120 | return a
121 | }
122 |
123 | func (a *APP) Bookshelf() *APP {
124 | switch a.CurrentApp {
125 | case BoluobaoLibAPP:
126 | newApp := a.Boluobao.APP().Bookshelf(sfContinueFunction, sfContentFunction)
127 | newApp.MergeText(sfMergeText(mergeTextToFile[boluobaomodel.ChapterList](newApp.GetBookInfo().NovelName)))
128 | case CiweimaoLibAPP:
129 | newApp := a.Ciweimao.APP().Bookshelf(hbookerContinueFunction, hbookerContentFunction)
130 | newApp.MergeText(hbookerMergeText(mergeTextToFile[hbookermodel.ChapterList](newApp.GetBookInfo().BookName)))
131 | }
132 | return a
133 | }
134 |
--------------------------------------------------------------------------------
/pkg/epub/fetchmedia.go:
--------------------------------------------------------------------------------
1 | package epub
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "io/ioutil"
9 | "net/http"
10 | "os"
11 | "path/filepath"
12 |
13 | "github.com/gabriel-vasile/mimetype"
14 | "github.com/vincent-petithory/dataurl"
15 | )
16 |
17 | // grabber is a top level structure that allows a custom http client.
18 | // if onlyChecl is true, the methods will not perform actual grab to spare memory and bandwidth
19 | type grabber struct {
20 | *http.Client
21 | }
22 |
23 | func (g grabber) checkMedia(mediaSource string) error {
24 | fetchErrors := make([]error, 0)
25 | for _, f := range []func(string, bool) (io.ReadCloser, error){
26 | g.localHandler,
27 | g.httpHandler,
28 | g.dataURLHandler,
29 | } {
30 | var err error
31 | source, err := f(mediaSource, true)
32 | if source != nil {
33 | source.Close()
34 | }
35 | if err == nil {
36 | return nil
37 | }
38 | fetchErrors = append(fetchErrors, err)
39 | }
40 | return &FileRetrievalError{Source: mediaSource, Err: fetchError(fetchErrors)}
41 | }
42 |
43 | // fetchMedia from mediaSource into mediaFolderPath as mediaFilename returning its type.
44 | // the mediaSource can be a URL, a local path or an inline dataurl (as specified in RFC 2397)
45 | func (g grabber) fetchMedia(mediaSource, mediaFolderPath, mediaFilename string) (mediaType string, err error) {
46 |
47 | mediaFilePath := filepath.Join(
48 | mediaFolderPath,
49 | mediaFilename,
50 | )
51 | // failfast, create the output file handler at the begining, if we cannot write the file, bail out
52 | w, err := filesystem.Create(mediaFilePath)
53 | if err != nil {
54 | return "", fmt.Errorf("unable to create file %s: %s", mediaFilePath, err)
55 | }
56 | defer w.Close()
57 | var source io.ReadCloser
58 | fetchErrors := make([]error, 0)
59 | for _, f := range []func(string, bool) (io.ReadCloser, error){
60 | g.localHandler,
61 | g.httpHandler,
62 | g.dataURLHandler,
63 | } {
64 | var err error
65 | source, err = f(mediaSource, false)
66 | if err != nil {
67 | fetchErrors = append(fetchErrors, err)
68 | continue
69 | }
70 | break
71 | }
72 | if source == nil {
73 | return "", &FileRetrievalError{Source: mediaSource, Err: fetchError(fetchErrors)}
74 |
75 | }
76 | defer source.Close()
77 |
78 | _, err = io.Copy(w, source)
79 | if err != nil {
80 | // There shouldn't be any problem with the writer, but the reader
81 | // might have an issue
82 | return "", &FileRetrievalError{Source: mediaSource, Err: err}
83 | }
84 |
85 | // Detect the mediaType
86 | r, err := filesystem.Open(mediaFilePath)
87 | if err != nil {
88 | return "", err
89 | }
90 | defer r.Close()
91 | mime, err := mimetype.DetectReader(r)
92 | if err != nil {
93 | panic(err)
94 | }
95 |
96 | // Is it CSS?
97 | mtype := mime.String()
98 | if mime.Is("text/plain") {
99 | if filepath.Ext(mediaSource) == ".css" || filepath.Ext(mediaFilename) == ".css" {
100 | mtype = "text/css"
101 | }
102 | }
103 | return mtype, nil
104 | }
105 |
106 | func (g grabber) httpHandler(mediaSource string, onlyCheck bool) (io.ReadCloser, error) {
107 | var resp *http.Response
108 | var err error
109 | if onlyCheck {
110 | resp, err = g.Head(mediaSource)
111 | } else {
112 | resp, err = g.Get(mediaSource)
113 | }
114 | if err != nil {
115 | return nil, err
116 | }
117 | if resp.StatusCode > 400 {
118 | return nil, errors.New("cannot get file, bad return code")
119 | }
120 | return resp.Body, nil
121 | }
122 |
123 | func (g grabber) localHandler(mediaSource string, onlyCheck bool) (io.ReadCloser, error) {
124 | if onlyCheck {
125 | if _, err := os.Stat(mediaSource); os.IsNotExist(err) {
126 | return nil, err
127 | }
128 | return nil, nil
129 | }
130 | return os.Open(mediaSource)
131 | }
132 |
133 | func (g grabber) dataURLHandler(mediaSource string, onlyCheck bool) (io.ReadCloser, error) {
134 | if onlyCheck {
135 | _, err := dataurl.DecodeString(mediaSource)
136 | return nil, err
137 | }
138 | data, err := dataurl.DecodeString(mediaSource)
139 | if err != nil {
140 | return nil, err
141 | }
142 | return ioutil.NopCloser(bytes.NewReader(data.Data)), nil
143 | }
144 |
145 | type fetchError []error
146 |
147 | func (f fetchError) Error() string {
148 | var message string
149 | for _, err := range f {
150 | message = fmt.Sprintf("%v\n %v", message, err.Error())
151 | }
152 | return message
153 | }
154 |
--------------------------------------------------------------------------------
/pkg/tools/tools.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "encoding/json"
7 | "fmt"
8 | "log"
9 | "os"
10 | "path"
11 | "regexp"
12 | "sort"
13 | "strconv"
14 | "strings"
15 | )
16 |
17 | func RegexpName(Name string) string {
18 | return regexp.MustCompile(`[\\/:*?"<>|]`).ReplaceAllString(Name, "")
19 | }
20 |
21 | func JsonString(jsonStruct any) string {
22 | if jsonInfo, err := json.MarshalIndent(jsonStruct, "", " "); err == nil {
23 | return string(jsonInfo)
24 | } else {
25 | log.Println(err)
26 | }
27 | return ""
28 | }
29 |
30 | func StandardContent(contentList []string) string {
31 | content := "" // clear content string
32 | for _, s := range contentList {
33 | if s != "" {
34 | content += "\n" + strings.ReplaceAll(s, " ", "")
35 | }
36 | }
37 | return content
38 | }
39 | func TestList(List []string, testString string) bool {
40 | for _, s := range List {
41 | if s == testString {
42 | return true
43 | }
44 | }
45 | return false
46 | }
47 | func TestIntList(List []int, testString string) bool {
48 | for _, s := range List {
49 | if strconv.Itoa(s) == testString {
50 | return true
51 | }
52 | }
53 | return false
54 | }
55 | func GetFileName(dirname string) []string {
56 | var file_list []string
57 | f, err := os.Open(dirname)
58 | if err != nil {
59 | log.Fatal(err)
60 | }
61 | if list, ok := f.Readdir(-1); ok == nil {
62 | _ = f.Close()
63 | sort.Slice(list, func(i, j int) bool { return list[i].Name() < list[j].Name() })
64 | for _, v := range list {
65 | file_list = append(file_list, v.Name())
66 | }
67 | return file_list
68 | } else {
69 | log.Fatal(ok)
70 | }
71 | return nil
72 | }
73 | func get_working_directory() string {
74 | dir, err := os.Getwd()
75 | if err != nil {
76 | log.Fatal(err)
77 | } else {
78 | return dir
79 | }
80 | return ""
81 | }
82 |
83 | func Mkdir(filePath string) string {
84 | file_path := path.Join(get_working_directory(), filePath)
85 | if err := os.MkdirAll(file_path, os.ModePerm); err != nil {
86 | fmt.Println(err)
87 | }
88 | return file_path
89 | }
90 |
91 | // InputStr input str
92 | func InputStr(introduction string) string {
93 | var input string
94 | // if search keyword is not empty, search book and download
95 | fmt.Printf(introduction)
96 | if _, err := fmt.Scanln(&input); err == nil {
97 | if input != "" {
98 | return input
99 | }
100 | }
101 | return InputStr(">")
102 | }
103 |
104 | func GET(prompt string) []string {
105 | compile, _ := regexp.Compile(`\s+`)
106 | inputs := compile.Split(strings.TrimSpace(Input(prompt)), -1)
107 | if len(inputs) > 0 && inputs[0] != "" {
108 | return inputs
109 | }
110 | return nil
111 | }
112 |
113 | func Input(prompt string) string {
114 | for {
115 | fmt.Printf(prompt)
116 | input, err := bufio.NewReader(os.Stdin).ReadString('\n')
117 | if err == nil {
118 | return input
119 | }
120 | }
121 | }
122 | func IsNum(s string) bool {
123 | _, err := strconv.ParseFloat(s, 64)
124 | return err == nil
125 | }
126 |
127 | // StrToInt string to int
128 | func StrToInt(str string) int {
129 | if i, err := strconv.Atoi(str); err == nil {
130 | return i
131 | }
132 | return 0
133 | }
134 |
135 | func FormatJson(jsonString []byte) {
136 | var str bytes.Buffer
137 | if err := json.Indent(&str, jsonString, "", " "); err == nil {
138 | fmt.Println(str.String())
139 | } else {
140 | log.Fatalln(err)
141 | }
142 | }
143 |
144 | // InputInt input int
145 | func InputInt(introduction string, max_indexes int) int {
146 | var input int
147 | // if search keyword is not empty, search book and download
148 | fmt.Printf(introduction)
149 | if _, err := fmt.Scanln(&input); err == nil {
150 | for {
151 | if input >= max_indexes {
152 | fmt.Println("you input index is out of range, please input again:")
153 | return InputInt(">", max_indexes)
154 | } else {
155 | return input
156 | }
157 | }
158 | } else {
159 | return InputInt(">", max_indexes)
160 | }
161 | }
162 |
163 | //func TestKeyword(Text string, keyword any) bool {
164 | // switch keyword.(type) {
165 | // case string:
166 | // return strings.Contains(Text, keyword.(string))
167 | // case int:
168 | // return strings.Contains(Text, strconv.Itoa(keyword.(int)))
169 | // default:
170 | // panic("keyword type error")
171 | // }
172 | //}
173 |
174 | // strconv.FormatBool()
175 | //func FormatBool(b bool) string {
176 | // if b {
177 | // return "true"
178 | // }
179 | // return "false"
180 | //}
181 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Noa Himesaka
2 |
3 |
4 |
5 |
6 |
7 | Download books from
8 | hbooker to read them.
9 |
10 |
11 | [简中](./docs/README_zh-CN.md) | [繁中](./docs/README_zh-TW.md)
12 |
13 | ---
14 |
15 | ## **Features**
16 |
17 | - The script implements download functions for hbooker Android API.
18 | - You can log in to your account and save your cookies in a `config.json` file.
19 | - Input the book ID or URL to download the book to a local directory.
20 | - Input the URL to download the book text from the URL.
21 | - Supports downloading EPUB files from hbooker.
22 | - Search for books by keyword and download the search results.
23 | - [ Warning ] The new version of book cache is incompatible with older versions of book cache.
24 |
25 | ---
26 |
27 | ## Sign in to your Ciweimao account
28 |
29 | - To use this script, you need to log in with your account and obtain your `token`.
30 | - The new version of hbooker adds GEETEST verification, which will be triggered if you enter incorrect information or
31 | log in multiple times.
32 | - The IP address may need to log in again after a few hours to avoid triggering the verification process. You can try
33 | changing the IP to avoid it.
34 |
35 | ---
36 |
37 | ## Accessing the API using tokens
38 |
39 | - Use tokens to access the API and bypass login.
40 | - Third-party captcha GEETEST has been added to the Ciweimao official server.
41 | - The Ciweimao login is protected by GEETEST, which seems impossible to circumvent.
42 | - You can capture packets of the Ciweimao Android App to obtain the account and login_token for logging in.
43 |
44 | ---
45 |
46 | ## **Example**
47 |
48 | ``` bash
49 | NAME:
50 | pineapple-backups - https://github.com/AlexiaVeronica/pineapple-backups
51 |
52 | USAGE:
53 | main.exe [global options] command [command options] [arguments...]
54 |
55 | VERSION:
56 | V.1.9.7
57 |
58 | COMMANDS:
59 | help, h Shows a list of commands or help for one command
60 |
61 | GLOBAL OPTIONS:
62 | -a value, --appType value cheng app type
63 | -d value, --download value book id
64 | -t, --token input hbooker token
65 | -m value, --maxThread value change max thread number (default: 16)
66 | -u value, --user value input account name
67 | -p value, --password value input password
68 | --update update book
69 | -s value, --search value search book by keyword
70 | -l, --login login local account
71 | -e, --epub start epub
72 | -b, --bookshelf show bookshelf
73 | --help, -h show help
74 | --version, -v print the version
75 | ```
76 |
77 | ## Thank you for JetBrains License
78 |
79 |
80 |
81 |
82 |
83 |
84 | - [Goland](https://www.jetbrains.com/go/)
85 |
86 | - I would like to express my sincere gratitude and appreciation to JetBrains for providing me with the license to use and leverage software related to open source tools.
87 |
88 | - Your software has helped me to accomplish my tasks faster and more efficiently in my daily work, while also providing me with appropriate quality assurance. This is important for my personal growth and business development.
89 |
90 | - Thank you for your outstanding contribution to promoting the development of open-source software community. I will continue to use and support your company's software in my work and leisure time, and actively participate in contributing to the open-source community.
91 |
92 | - Once again, thank you JetBrains for your support!
93 |
94 | - Best regards, Alexia
95 |
96 | ## **Disclaimers**
97 |
98 | - This tool is for educational purposes only. Please delete it from your computer within 24 hours after downloading.
99 | - Please respect the copyright and do not distribute the crawled books yourself.
100 | - The authors or copyright holders shall not be liablefor any claim, damages, or other liability, whether in an action
101 | of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings
102 | in the software, including but not limited to the use of the software for illegal purposes. The author is not
103 | responsible for any legal consequences.
104 | - If you have any questions, please contact me via GitHub issues or email.
105 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "github.com/AlexiaVeronica/pineapple-backups/pkg/config"
6 | "log"
7 | "os"
8 | "strings"
9 |
10 | "github.com/AlexiaVeronica/input"
11 | "github.com/AlexiaVeronica/pineapple-backups/pkg/app"
12 | "github.com/AlexiaVeronica/pineapple-backups/pkg/tools"
13 | "github.com/urfave/cli"
14 | )
15 |
16 | var (
17 | apps *app.APP
18 | cmd = &commandLines{MaxThread: 32}
19 | )
20 |
21 | type commandLines struct {
22 | BookID, Account, Password, SearchKey string
23 | MaxThread int
24 | Token, Login, ShowInfo, Update, Epub, BookShelf bool
25 | }
26 |
27 | const (
28 | FlagAppType = "appType"
29 | FlagDownload = "download"
30 | FlagToken = "token"
31 | FlagMaxThread = "maxThread"
32 | FlagUser = "user"
33 | FlagPassword = "password"
34 | FlagUpdate = "update"
35 | FlagSearch = "search"
36 | FlagLogin = "login"
37 | FlagEpub = "epub"
38 | FlagBookShelf = "bookshelf"
39 | )
40 |
41 | func init() {
42 |
43 | setupConfig()
44 | apps = app.NewApp()
45 | setupCLI()
46 | setupTokens()
47 | }
48 |
49 | func setupConfig() {
50 | if _, err := os.Stat("./config.json"); os.IsNotExist(err) {
51 | fmt.Println("config.json does not exist, creating a new one!")
52 | } else {
53 | config.LoadConfig()
54 | }
55 | config.UpdateConfig()
56 | }
57 |
58 | func setupCLI() {
59 | newCli := cli.NewApp()
60 | newCli.Name = "pineapple-backups"
61 | newCli.Version = "V.2.2.1"
62 | newCli.Usage = "https://github.com/AlexiaVeronica/pineapple-backups"
63 | newCli.Flags = defineFlags()
64 | newCli.Action = validateAppType
65 |
66 | if err := newCli.Run(os.Args); err != nil {
67 | log.Fatal(err)
68 | }
69 | }
70 |
71 | func defineFlags() []cli.Flag {
72 | return []cli.Flag{
73 | cli.StringFlag{Name: fmt.Sprintf("a, %s", FlagAppType), Value: app.BoluobaoLibAPP, Usage: "change app type", Destination: &apps.CurrentApp},
74 | cli.StringFlag{Name: fmt.Sprintf("d, %s", FlagDownload), Usage: "book id", Destination: &cmd.BookID},
75 | cli.BoolFlag{Name: fmt.Sprintf("t, %s", FlagToken), Usage: "input hbooker token", Destination: &cmd.Token},
76 | cli.IntFlag{Name: fmt.Sprintf("m, %s", FlagMaxThread), Value: 16, Usage: "change max thread number", Destination: &cmd.MaxThread},
77 | cli.StringFlag{Name: fmt.Sprintf("u, %s", FlagUser), Usage: "input account name", Destination: &cmd.Account},
78 | cli.StringFlag{Name: fmt.Sprintf("p, %s", FlagPassword), Usage: "input password", Destination: &cmd.Password},
79 | cli.BoolFlag{Name: FlagUpdate, Usage: "update book", Destination: &cmd.Update},
80 | cli.StringFlag{Name: fmt.Sprintf("s, %s", FlagSearch), Usage: "search book by keyword", Destination: &cmd.SearchKey},
81 | cli.BoolFlag{Name: fmt.Sprintf("l, %s", FlagLogin), Usage: "login local account", Destination: &cmd.Login},
82 | cli.BoolFlag{Name: fmt.Sprintf("e, %s", FlagEpub), Usage: "start epub", Destination: &cmd.Epub},
83 | cli.BoolFlag{Name: fmt.Sprintf("b, %s", FlagBookShelf), Usage: "show bookshelf", Destination: &cmd.BookShelf},
84 | }
85 | }
86 |
87 | func validateAppType(c *cli.Context) {
88 | if !isValidAppType(apps.CurrentApp) {
89 | log.Fatalf("%s app type error", apps.CurrentApp)
90 | }
91 | }
92 |
93 | func isValidAppType(appType string) bool {
94 | return strings.Contains(appType, app.CiweimaoLibAPP) || strings.Contains(appType, app.BoluobaoLibAPP)
95 | }
96 |
97 | func setupTokens() {
98 | apps.Ciweimao.SetToken(config.Apps.Hbooker.Account, config.Apps.Hbooker.LoginToken)
99 | apps.Boluobao.Cookie = config.Apps.Sfacg.Cookie
100 | }
101 |
102 | func shellSwitch(inputs []string) {
103 | if len(inputs) == 0 {
104 | fmt.Println("No command provided.")
105 | return
106 | }
107 |
108 | switch inputs[0] {
109 | case "up", "update":
110 | // Update function placeholder
111 | case "a", "app":
112 | handleAppSwitch(inputs)
113 | case "d", "download":
114 | handleDownload(inputs)
115 | case "bs", "bookshelf":
116 | apps.Bookshelf()
117 | case "s", "search":
118 | apps.SearchDetailed(inputs[1])
119 | case "l", "login":
120 | handleLogin(inputs)
121 | case "t", "token":
122 | apps.Ciweimao.SetToken(inputs[1], inputs[2])
123 | default:
124 | fmt.Println("command not found, please input help to see the command list:", inputs[0])
125 | }
126 | }
127 |
128 | func handleAppSwitch(inputs []string) {
129 | if len(inputs) < 2 {
130 | fmt.Println("app type required. Example: app ")
131 | return
132 | }
133 |
134 | if tools.TestList([]string{app.BoluobaoLibAPP, app.CiweimaoLibAPP}, inputs[1]) {
135 | apps.CurrentApp = inputs[1]
136 | } else {
137 | fmt.Println("app type error, please input again.")
138 | }
139 | }
140 |
141 | func handleDownload(inputs []string) {
142 | if len(inputs) == 2 {
143 | apps.DownloadBookByBookId(inputs[1])
144 | } else {
145 | fmt.Println("input book id or url, like: download ")
146 | }
147 | }
148 |
149 | func handleLogin(inputs []string) {
150 | if len(inputs) < 3 {
151 | fmt.Println("you must input account and password, like: -login account password")
152 | return
153 | }
154 | switch apps.CurrentApp {
155 | case app.CiweimaoLibAPP:
156 | apps.Ciweimao.SetToken(inputs[1], inputs[2])
157 | case app.BoluobaoLibAPP:
158 | loginStatus, err := apps.Boluobao.API().Login(inputs[1], inputs[2])
159 | if err == nil {
160 | apps.Boluobao.Cookie = loginStatus.Cookie
161 | }
162 | }
163 | }
164 |
165 | func shell(messageOpen bool) {
166 | if messageOpen {
167 | for _, message := range config.HelpMessage {
168 | fmt.Println("[info]", message)
169 | }
170 | }
171 | for {
172 | inputRes := input.StringInput(">")
173 | if len(inputRes) > 0 {
174 | shellSwitch(strings.Split(inputRes, " "))
175 | }
176 | }
177 | }
178 |
179 | func main() {
180 | if len(os.Args) > 1 {
181 | handleCommandLine()
182 | } else {
183 | shell(true)
184 | }
185 | }
186 |
187 | func handleCommandLine() {
188 | switch {
189 | case cmd.Login:
190 | loginStatus, err := apps.Boluobao.API().Login(cmd.Account, cmd.Password)
191 | if err == nil {
192 | apps.Boluobao.Cookie = loginStatus.Cookie
193 | }
194 | case cmd.BookID != "":
195 | apps.DownloadBookByBookId(cmd.BookID)
196 | case cmd.SearchKey != "":
197 | apps.SearchDetailed(cmd.SearchKey)
198 | case cmd.Update:
199 | // Update function placeholder
200 | case cmd.Token:
201 | apps.Ciweimao.SetToken(input.StringInput("Please input account:"), input.StringInput("Please input token:"))
202 | case cmd.BookShelf:
203 | apps.Bookshelf()
204 | default:
205 | fmt.Println("command not found, please input help to see the command list:", os.Args[1])
206 | }
207 | }
208 |
--------------------------------------------------------------------------------
/pkg/epub/toc.go:
--------------------------------------------------------------------------------
1 | package epub
2 |
3 | import (
4 | "encoding/xml"
5 | "fmt"
6 | "path/filepath"
7 | "strconv"
8 | )
9 |
10 | const (
11 | tocNavBodyTemplate = `
12 |
13 | Table of Contents
14 |
15 |
16 |
17 | `
18 | tocNavFilename = "nav.xhtml"
19 | tocNavItemID = "nav"
20 | tocNavItemProperties = "nav"
21 | tocNavEpubType = "toc"
22 |
23 | tocNcxFilename = "toc.ncx"
24 | tocNcxItemID = "ncx"
25 | tocNcxTemplate = `
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | `
40 |
41 | xmlnsEpub = "http://www.idpf.org/2007/ops"
42 | )
43 |
44 | // toc implements the EPUB table of contents
45 | type toc struct {
46 | // This holds the body XML for the EPUB v3 TOC file (nav.xhtml). Since this is
47 | // an XHTML file, the rest of the structure is handled by the xhtml type
48 | //
49 | // Sample: https://github.com/bmaupin/epub-samples/blob/master/minimal-v3plus2/EPUB/nav.xhtml
50 | // Spec: http://www.idpf.org/epub/301/spec/epub-contentdocs.html#sec-xhtml-nav
51 | navXML *tocNavBody
52 |
53 | // This holds the XML for the EPUB v2 TOC file (toc.ncx). This is added so the
54 | // resulting EPUB v3 file will still work with devices that only support EPUB v2
55 | //
56 | // Sample: https://github.com/bmaupin/epub-samples/blob/master/minimal-v3plus2/EPUB/toc.ncx
57 | // Spec: http://www.idpf.org/epub/20/spec/OPF_2.0.1_draft.htm#Section2.4.1
58 | ncxXML *tocNcxRoot
59 |
60 | title string // EPUB title
61 | author string // EPUB author
62 | }
63 |
64 | type tocNavBody struct {
65 | XMLName xml.Name `xml:"nav"`
66 | EpubType string `xml:"epub:type,attr"`
67 | H1 string `xml:"h1"`
68 | Links []tocNavItem `xml:"ol>li"`
69 | }
70 |
71 | type tocNavItem struct {
72 | A tocNavLink `xml:"a"`
73 | Children *[]tocNavItem `xml:"ol>li,omitempty"`
74 | }
75 |
76 | type tocNavLink struct {
77 | XMLName xml.Name `xml:"a"`
78 | Href string `xml:"href,attr"`
79 | Data string `xml:",chardata"`
80 | }
81 |
82 | type tocNcxRoot struct {
83 | XMLName xml.Name `xml:"http://www.daisy.org/z3986/2005/ncx/ ncx"`
84 | Version string `xml:"version,attr"`
85 | Meta tocNcxMeta `xml:"head>meta"`
86 | Title string `xml:"docTitle>text"`
87 | Author string `xml:"docAuthor>text"`
88 | NavMap []tocNcxNavPoint `xml:"navMap>navPoint"`
89 | }
90 |
91 | type tocNcxContent struct {
92 | Src string `xml:"src,attr"`
93 | }
94 |
95 | type tocNcxMeta struct {
96 | Name string `xml:"name,attr"`
97 | Content string `xml:"content,attr"`
98 | }
99 |
100 | type tocNcxNavPoint struct {
101 | XMLName xml.Name `xml:"navPoint"`
102 | ID string `xml:"id,attr"`
103 | Text string `xml:"navLabel>text"`
104 | Content tocNcxContent `xml:"content"`
105 | Children *[]tocNcxNavPoint `xml:"navPoint,omitempty"`
106 | }
107 |
108 | // Constructor for toc
109 | func newToc() *toc {
110 | t := &toc{}
111 |
112 | t.navXML = newTocNavXML()
113 |
114 | t.ncxXML = newTocNcxXML()
115 |
116 | return t
117 | }
118 |
119 | // Constructor for tocNavBody
120 | func newTocNavXML() *tocNavBody {
121 | b := &tocNavBody{
122 | EpubType: tocNavEpubType,
123 | }
124 | err := xml.Unmarshal([]byte(tocNavBodyTemplate), &b)
125 | if err != nil {
126 | panic(fmt.Sprintf(
127 | "Error unmarshalling tocNavBody: %s\n"+
128 | "\ttocNavBody=%#v\n"+
129 | "\ttocNavBodyTemplate=%s",
130 | err,
131 | *b,
132 | tocNavBodyTemplate))
133 | }
134 |
135 | return b
136 | }
137 |
138 | // Constructor for tocNcxRoot
139 | func newTocNcxXML() *tocNcxRoot {
140 | n := &tocNcxRoot{}
141 |
142 | err := xml.Unmarshal([]byte(tocNcxTemplate), &n)
143 | if err != nil {
144 | panic(fmt.Sprintf(
145 | "Error unmarshalling tocNcxRoot: %s\n"+
146 | "\ttocNcxRoot=%#v\n"+
147 | "\ttocNcxTemplate=%s",
148 | err,
149 | *n,
150 | tocNcxTemplate))
151 | }
152 |
153 | return n
154 | }
155 |
156 | // Add a section to the TOC (navXML as well as ncxXML)
157 | func (t *toc) addSection(index int, title string, relativePath string) {
158 | relativePath = filepath.ToSlash(relativePath)
159 | l := &tocNavItem{
160 | A: tocNavLink{
161 | Href: relativePath,
162 | Data: title,
163 | },
164 | Children: nil,
165 | }
166 | t.navXML.Links = append(t.navXML.Links, *l)
167 |
168 | np := &tocNcxNavPoint{
169 | ID: "navPoint-" + strconv.Itoa(index),
170 | Text: title,
171 | Content: tocNcxContent{
172 | Src: relativePath,
173 | },
174 | Children: nil,
175 | }
176 | t.ncxXML.NavMap = append(t.ncxXML.NavMap, *np)
177 | }
178 |
179 | // Add a sub section to the TOC (navXML as well as ncxXML)
180 | func (t *toc) addSubSection(parent string, index int, title string, relativePath string) {
181 | var parentNcxIndex int
182 | var parentNavIndex int
183 |
184 | relativePath = filepath.ToSlash(relativePath)
185 | parent = filepath.ToSlash(parent)
186 |
187 | for index, nav := range t.navXML.Links {
188 | if nav.A.Href == parent {
189 | parentNavIndex = index
190 | }
191 | }
192 | l := tocNavItem{
193 | A: tocNavLink{
194 | Href: relativePath,
195 | Data: title,
196 | },
197 | }
198 | if len(t.navXML.Links) > parentNavIndex {
199 | // Create a new array if none exists
200 | if t.navXML.Links[parentNavIndex].Children == nil {
201 | n := make([]tocNavItem, 0)
202 | t.navXML.Links[parentNavIndex].Children = &n
203 | }
204 | children := append(*t.navXML.Links[parentNavIndex].Children, l)
205 | t.navXML.Links[parentNavIndex].Children = &children
206 | } else {
207 | t.navXML.Links = append(t.navXML.Links, l)
208 | }
209 |
210 | // Get parent object
211 | for index, ncx := range t.ncxXML.NavMap {
212 | if ncx.Content.Src == parent {
213 | parentNcxIndex = index
214 | }
215 | }
216 | np := tocNcxNavPoint{
217 | ID: "navPoint-" + strconv.Itoa(index),
218 | Text: title,
219 | Content: tocNcxContent{
220 | Src: relativePath,
221 | },
222 | Children: nil,
223 | }
224 | if parentNcxIndex > len(t.ncxXML.NavMap) {
225 | if t.ncxXML.NavMap[parentNcxIndex].Children == nil {
226 | n := make([]tocNcxNavPoint, 0)
227 | t.ncxXML.NavMap[parentNcxIndex].Children = &n
228 | }
229 | children := append(*t.ncxXML.NavMap[parentNcxIndex].Children, np)
230 | t.ncxXML.NavMap[parentNcxIndex].Children = &children
231 | } else {
232 | t.ncxXML.NavMap = append(t.ncxXML.NavMap, np)
233 | }
234 | }
235 |
236 | func (t *toc) setIdentifier(identifier string) {
237 | t.ncxXML.Meta.Content = identifier
238 | }
239 |
240 | func (t *toc) setTitle(title string) {
241 | t.title = title
242 | }
243 |
244 | func (t *toc) setAuthor(author string) {
245 | t.author = author
246 | }
247 |
248 | // Write the TOC files
249 | func (t *toc) write(tempDir string) {
250 | t.writeNavDoc(tempDir)
251 | t.writeNcxDoc(tempDir)
252 | }
253 |
254 | // Write the the EPUB v3 TOC file (nav.xhtml) to the temporary directory
255 | func (t *toc) writeNavDoc(tempDir string) {
256 | navBodyContent, err := xml.MarshalIndent(t.navXML, " ", " ")
257 | if err != nil {
258 | panic(fmt.Sprintf(
259 | "Error marshalling XML for EPUB v3 TOC file: %s\n"+
260 | "\tXML=%#v",
261 | err,
262 | t.navXML))
263 | }
264 |
265 | n := newXhtml(string(navBodyContent))
266 | n.setXmlnsEpub(xmlnsEpub)
267 | n.setTitle(t.title)
268 |
269 | navFilePath := filepath.Join(tempDir, contentFolderName, tocNavFilename)
270 | n.write(navFilePath)
271 | }
272 |
273 | // Write the EPUB v2 TOC file (toc.ncx) to the temporary directory
274 | func (t *toc) writeNcxDoc(tempDir string) {
275 | t.ncxXML.Title = t.title
276 | t.ncxXML.Author = t.author
277 |
278 | ncxFileContent, err := xml.MarshalIndent(t.ncxXML, "", " ")
279 | if err != nil {
280 | panic(fmt.Sprintf(
281 | "Error marshalling XML for EPUB v2 TOC file: %s\n"+
282 | "\tXML=%#v",
283 | err,
284 | t.ncxXML))
285 | }
286 |
287 | // Add the xml header to the output
288 | ncxFileContent = append([]byte(xml.Header), ncxFileContent...)
289 | // It's generally nice to have files end with a newline
290 | ncxFileContent = append(ncxFileContent, "\n"...)
291 |
292 | ncxFilePath := filepath.Join(tempDir, contentFolderName, tocNcxFilename)
293 | if err := filesystem.WriteFile(ncxFilePath, []byte(ncxFileContent), filePermissions); err != nil {
294 | panic(fmt.Sprintf("Error writing EPUB v2 TOC file: %s", err))
295 | }
296 | }
297 |
--------------------------------------------------------------------------------
/pkg/epub/pkg.go:
--------------------------------------------------------------------------------
1 | package epub
2 |
3 | import (
4 | "encoding/xml"
5 | "fmt"
6 | "path/filepath"
7 | "time"
8 | )
9 |
10 | const (
11 | pkgAuthorID = "role"
12 | pkgAuthorData = "aut"
13 | pkgAuthorProperty = "role"
14 | pkgAuthorRefines = "#creator"
15 | pkgAuthorScheme = "marc:relators"
16 | pkgCreatorID = "creator"
17 | pkgFileTemplate = `
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | `
31 | pkgModifiedProperty = "dcterms:modified"
32 | pkgUniqueIdentifier = "pub-id"
33 |
34 | xmlnsDc = "http://purl.org/dc/elements/1.1/"
35 | )
36 |
37 | // pkg implements the package document file (package.opf), which contains
38 | // metadata about the EPUB (title, author, etc) as well as a list of files the
39 | // EPUB contains.
40 | //
41 | // Sample: https://github.com/bmaupin/epub-samples/blob/master/minimal-v3plus2/EPUB/package.opf
42 | // Spec: http://www.idpf.org/epub/301/spec/epub-publications.html
43 | type pkg struct {
44 | xml *pkgRoot
45 | authorMeta *pkgMeta
46 | coverMeta *pkgMeta
47 | modifiedMeta *pkgMeta
48 | }
49 |
50 | // This holds the actual XML for the package file
51 | type pkgRoot struct {
52 | XMLName xml.Name `xml:"http://www.idpf.org/2007/opf package"`
53 | UniqueIdentifier string `xml:"unique-identifier,attr"`
54 | Version string `xml:"version,attr"`
55 | Metadata pkgMetadata `xml:"metadata"`
56 | ManifestItems []pkgItem `xml:"manifest>item"`
57 | Spine pkgSpine `xml:"spine"`
58 | }
59 |
60 | // , e.g. the author
61 | type pkgCreator struct {
62 | XMLName xml.Name `xml:"dc:creator"`
63 | ID string `xml:"id,attr"`
64 | Data string `xml:",chardata"`
65 | }
66 |
67 | // , where the unique identifier is stored
68 | // Ex: urn:uuid:fe93046f-af57-475a-a0cb-a0d4bc99ba6d
69 | type pkgIdentifier struct {
70 | ID string `xml:"id,attr"`
71 | Data string `xml:",chardata"`
72 | }
73 |
74 | // - elements, one per each file stored in the EPUB
75 | // Ex:
76 | //
77 | //
78 | //
79 | type pkgItem struct {
80 | ID string `xml:"id,attr"`
81 | Href string `xml:"href,attr"`
82 | MediaType string `xml:"media-type,attr"`
83 | Properties string `xml:"properties,attr,omitempty"`
84 | }
85 |
86 | // elements, which define the reading order
87 | // Ex:
88 | type pkgItemref struct {
89 | Idref string `xml:"idref,attr"`
90 | }
91 |
92 | // The element, which contains modified date, role of the creator (e.g.
93 | // author), etc
94 | // Ex: aut
95 | //
96 | // 2011-01-01T12:00:00Z
97 | type pkgMeta struct {
98 | Refines string `xml:"refines,attr,omitempty"`
99 | Property string `xml:"property,attr,omitempty"`
100 | Scheme string `xml:"scheme,attr,omitempty"`
101 | ID string `xml:"id,attr,omitempty"`
102 | Data string `xml:",chardata"`
103 | Name string `xml:"name,attr,omitempty"`
104 | Content string `xml:"content,attr,omitempty"`
105 | }
106 |
107 | // The element
108 | type pkgMetadata struct {
109 | XmlnsDc string `xml:"xmlns:dc,attr"`
110 | Identifier pkgIdentifier `xml:"dc:identifier"`
111 | // Ex: Your title here
112 | Title string `xml:"dc:title"`
113 | // Ex: en
114 | Language string `xml:"dc:language"`
115 | Description string `xml:"dc:description,omitempty"`
116 | Creator *pkgCreator
117 | Meta []pkgMeta `xml:"meta"`
118 | }
119 |
120 | // The element
121 | type pkgSpine struct {
122 | Items []pkgItemref `xml:"itemref"`
123 | Toc string `xml:"toc,attr"`
124 | Ppd string `xml:"page-progression-direction,attr,omitempty"`
125 | }
126 |
127 | // Constructor for pkg
128 | func newPackage() *pkg {
129 | p := &pkg{
130 | xml: &pkgRoot{
131 | Metadata: pkgMetadata{
132 | XmlnsDc: xmlnsDc,
133 | Identifier: pkgIdentifier{
134 | ID: pkgUniqueIdentifier,
135 | },
136 | },
137 | },
138 | }
139 |
140 | err := xml.Unmarshal([]byte(pkgFileTemplate), &p.xml)
141 | if err != nil {
142 | panic(fmt.Sprintf(
143 | "Error unmarshalling package file XML: %s\n"+
144 | "\tp.xml=%#v\n"+
145 | "\tpkgFileTemplate=%s",
146 | err,
147 | *p.xml,
148 | pkgFileTemplate))
149 | }
150 |
151 | return p
152 | }
153 |
154 | func (p *pkg) addToManifest(id string, href string, mediaType string, properties string) {
155 | href = filepath.ToSlash(href)
156 | i := &pkgItem{
157 | ID: id,
158 | Href: href,
159 | MediaType: mediaType,
160 | Properties: properties,
161 | }
162 | p.xml.ManifestItems = append(p.xml.ManifestItems, *i)
163 | }
164 |
165 | func (p *pkg) addToSpine(id string) {
166 | i := &pkgItemref{
167 | Idref: id,
168 | }
169 |
170 | p.xml.Spine.Items = append(p.xml.Spine.Items, *i)
171 | }
172 |
173 | func (p *pkg) setAuthor(author string) {
174 | p.xml.Metadata.Creator = &pkgCreator{
175 | Data: author,
176 | ID: pkgCreatorID,
177 | }
178 | p.authorMeta = &pkgMeta{
179 | Data: pkgAuthorData,
180 | ID: pkgAuthorID,
181 | Property: pkgAuthorProperty,
182 | Refines: pkgAuthorRefines,
183 | Scheme: pkgAuthorScheme,
184 | }
185 |
186 | p.xml.Metadata.Meta = updateMeta(p.xml.Metadata.Meta, p.authorMeta)
187 | }
188 |
189 | // Add an EPUB 2 cover meta element for backward compatibility (http://idpf.org/forum/topic-715)
190 | func (p *pkg) setCover(coverRef string) {
191 | p.coverMeta = &pkgMeta{
192 | Name: "cover",
193 | Content: coverRef,
194 | }
195 | p.xml.Metadata.Meta = updateMeta(p.xml.Metadata.Meta, p.coverMeta)
196 | }
197 |
198 | func (p *pkg) setIdentifier(identifier string) {
199 | p.xml.Metadata.Identifier.Data = identifier
200 | }
201 |
202 | func (p *pkg) setLang(lang string) {
203 | p.xml.Metadata.Language = lang
204 | }
205 |
206 | func (p *pkg) setDescription(desc string) {
207 | p.xml.Metadata.Description = desc
208 | }
209 |
210 | func (p *pkg) setPpd(direction string) {
211 | p.xml.Spine.Ppd = direction
212 | }
213 |
214 | func (p *pkg) setModified(timestamp string) {
215 | p.modifiedMeta = &pkgMeta{
216 | Data: timestamp,
217 | Property: pkgModifiedProperty,
218 | }
219 |
220 | p.xml.Metadata.Meta = updateMeta(p.xml.Metadata.Meta, p.modifiedMeta)
221 | }
222 |
223 | func (p *pkg) setTitle(title string) {
224 | p.xml.Metadata.Title = title
225 | }
226 |
227 | // Update the element
228 | func updateMeta(a []pkgMeta, m *pkgMeta) []pkgMeta {
229 | indexToReplace := -1
230 |
231 | if len(a) > 0 {
232 | // If we've already added the modified meta element to the meta array
233 | for i, meta := range a {
234 | if meta == *m {
235 | indexToReplace = i
236 | break
237 | }
238 | }
239 | }
240 |
241 | // If the array is empty or the meta element isn't in it
242 | if indexToReplace == -1 {
243 | // Add the meta element to the array of meta elements
244 | a = append(a, *m)
245 |
246 | // If the meta element is found
247 | } else {
248 | // Replace it
249 | a[indexToReplace] = *m
250 | }
251 |
252 | return a
253 | }
254 |
255 | // Write the package file to the temporary directory
256 | func (p *pkg) write(tempDir string) {
257 | now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
258 | p.setModified(now)
259 |
260 | pkgFilePath := filepath.Join(tempDir, contentFolderName, pkgFilename)
261 |
262 | output, err := xml.MarshalIndent(p.xml, "", " ")
263 | if err != nil {
264 | panic(fmt.Sprintf(
265 | "Error marshalling XML for package file: %s\n"+
266 | "\tXML=%#v",
267 | err,
268 | p.xml))
269 | }
270 | // Add the xml header to the output
271 | pkgFileContent := append([]byte(xml.Header), output...)
272 | // It's generally nice to have files end with a newline
273 | pkgFileContent = append(pkgFileContent, "\n"...)
274 |
275 | if err := filesystem.WriteFile(pkgFilePath, []byte(pkgFileContent), filePermissions); err != nil {
276 | panic(fmt.Sprintf("Error writing package file: %s", err))
277 | }
278 | }
279 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/AlexiaVeronica/boluobaoLib v0.3.2 h1:iZbeRx8z6H3b2G6iVr7oq0ARnN8q9Vf9Dzu3toiGoZQ=
2 | github.com/AlexiaVeronica/boluobaoLib v0.3.2/go.mod h1:gW+XZnRWGWz1DZyXozWx8SiaL1v5Gcl7RcMD7NpKsL0=
3 | github.com/AlexiaVeronica/hbookerLib v0.4.2 h1:gp2BUnlDFUM+TbLLVRsrhhWI+LI+QbLLMCsnnSf8xt8=
4 | github.com/AlexiaVeronica/hbookerLib v0.4.2/go.mod h1:lpY244vSpe5o6PpUP+IG4rr90mkW+3B+JByQhB+Oy4U=
5 | github.com/AlexiaVeronica/input v0.0.1 h1:2oRxd6yvYpZA9Ky0qAevlreXzQWE1BmrWoP0V7MM4Cc=
6 | github.com/AlexiaVeronica/input v0.0.1/go.mod h1:achI9MFvTjmqBNrigrxJyumEq81vsuFinLUaMPssGVE=
7 | github.com/AlexiaVeronica/req/v3 v3.43.5 h1:KqnIo1uC6vWUSPhq5uEGpOnkpJWIWDyfjwcbU/FwykM=
8 | github.com/AlexiaVeronica/req/v3 v3.43.5/go.mod h1:7/tqstMvJeMJFPIgVfk9ZQQlR6BJ3qP++sn6Shx8VRo=
9 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
10 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
11 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
12 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
13 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
14 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
15 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
16 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
19 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
20 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
21 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
22 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
23 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
24 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
25 | github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
26 | github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
27 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
28 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
29 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
30 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
31 | github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7 h1:y3N7Bm7Y9/CtpiVkw/ZWj6lSlDF3F74SfKwfTCer72Q=
32 | github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
33 | github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
34 | github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
35 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
36 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
37 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
38 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
39 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
40 | github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
41 | github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
42 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
43 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
44 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
45 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
46 | github.com/onsi/ginkgo/v2 v2.16.0 h1:7q1w9frJDzninhXxjZd+Y/x54XNjG/UlRLIYPZafsPM=
47 | github.com/onsi/ginkgo/v2 v2.16.0/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs=
48 | github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=
49 | github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
50 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
51 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
52 | github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
53 | github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
54 | github.com/quic-go/quic-go v0.41.0 h1:aD8MmHfgqTURWNJy48IYFg2OnxwHT3JL7ahGs73lb4k=
55 | github.com/quic-go/quic-go v0.41.0/go.mod h1:qCkNjqczPEvgsOnxZ0eCD14lv+B2LHlFAB++CNOh9hA=
56 | github.com/refraction-networking/utls v1.6.3 h1:MFOfRN35sSx6K5AZNIoESsBuBxS2LCgRilRIdHb6fDc=
57 | github.com/refraction-networking/utls v1.6.3/go.mod h1:yil9+7qSl+gBwJqztoQseO6Pr3h62pQoY1lXiNR/FPs=
58 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
59 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
60 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
61 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
62 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
63 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
64 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
65 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
66 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
67 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
68 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
69 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
70 | github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk=
71 | github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA=
72 | github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
73 | github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
74 | go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
75 | go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
76 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
77 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
78 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
79 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
80 | golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
81 | golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
82 | golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
83 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
84 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
85 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
86 | golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
87 | golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
88 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
89 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
90 | golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
91 | golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
92 | google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
93 | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
94 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
95 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
96 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
97 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
98 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
99 |
--------------------------------------------------------------------------------
/pkg/epub/write.go:
--------------------------------------------------------------------------------
1 | package epub
2 |
3 | import (
4 | "archive/zip"
5 | "fmt"
6 | "io"
7 | "io/fs"
8 | "os"
9 | "path/filepath"
10 | "unicode"
11 | "unicode/utf8"
12 |
13 | "github.com/gofrs/uuid"
14 | )
15 |
16 | // UnableToCreateEpubError is thrown by Write if it cannot create the destination EPUB file
17 | type UnableToCreateEpubError struct {
18 | Path string // The path that was given to Write to create the EPUB
19 | Err error // The underlying error that was thrown
20 | }
21 |
22 | func (e *UnableToCreateEpubError) Error() string {
23 | return fmt.Sprintf("Error creating EPUB at %q: %+v", e.Path, e.Err)
24 | }
25 |
26 | const (
27 | containerFilename = "container.xml"
28 | containerFileTemplate = `
29 |
30 |
31 |
32 |
33 |
34 | `
35 | // This seems to be the standard based on the latest EPUB spec:
36 | // http://www.idpf.org/epub/31/spec/epub-ocf.html
37 | contentFolderName = "EPUB"
38 | coverImageProperties = "cover-image"
39 | // Permissions for any new directories we create
40 | dirPermissions = 0755
41 | // Permissions for any new files we create
42 | filePermissions = 0644
43 | mediaTypeCSS = "text/css"
44 | mediaTypeEpub = "application/epub+zip"
45 | mediaTypeJpeg = "image/jpeg"
46 | mediaTypeNcx = "application/x-dtbncx+xml"
47 | mediaTypeXhtml = "application/xhtml+xml"
48 | metaInfFolderName = "META-INF"
49 | mimetypeFilename = "mimetype"
50 | pkgFilename = "package.opf"
51 | tempDirPrefix = "go-epub"
52 | xhtmlFolderName = "xhtml"
53 | )
54 |
55 | // WriteTo the dest io.Writer. The return value is the number of bytes written. Any error encountered during the write is also returned.
56 | func (e *Epub) WriteTo(dst io.Writer) (int64, error) {
57 | e.Lock()
58 | defer e.Unlock()
59 | tempDir := uuid.Must(uuid.NewV4()).String()
60 |
61 | err := filesystem.Mkdir(tempDir, dirPermissions)
62 | if err != nil {
63 | panic(fmt.Sprintf("Error creating temp directory: %s", err))
64 | }
65 | defer func() {
66 | if err := filesystem.RemoveAll(tempDir); err != nil {
67 | panic(fmt.Sprintf("Error removing temp directory: %s", err))
68 | }
69 | }()
70 | writeMimetype(tempDir)
71 | createEpubFolders(tempDir)
72 |
73 | // Must be called after:
74 | // createEpubFolders()
75 | writeContainerFile(tempDir)
76 |
77 | // Must be called after:
78 | // createEpubFolders()
79 | err = e.writeCSSFiles(tempDir)
80 | if err != nil {
81 | return 0, err
82 | }
83 |
84 | // Must be called after:
85 | // createEpubFolders()
86 | err = e.writeFonts(tempDir)
87 | if err != nil {
88 | return 0, err
89 | }
90 |
91 | // Must be called after:
92 | // createEpubFolders()
93 | err = e.writeImages(tempDir)
94 | if err != nil {
95 | return 0, err
96 | }
97 |
98 | // Must be called after:
99 | // createEpubFolders()
100 | err = e.writeVideos(tempDir)
101 | if err != nil {
102 | return 0, err
103 | }
104 |
105 | // Must be called after:
106 | // createEpubFolders()
107 | e.writeSections(tempDir)
108 |
109 | // Must be called after:
110 | // createEpubFolders()
111 | // writeSections()
112 | e.writeToc(tempDir)
113 |
114 | // Must be called after:
115 | // createEpubFolders()
116 | // writeCSSFiles()
117 | // writeImages()
118 | // writeVideos()
119 | // writeSections()
120 | // writeToc()
121 | e.writePackageFile(tempDir)
122 | // Must be called last
123 | return e.writeEpub(tempDir, dst)
124 | }
125 |
126 | // Write writes the EPUB file. The destination path must be the full path to
127 | // the resulting file, including filename and extension.
128 | // The result is always writen to the local filesystem even if the underlying storage is in memory.
129 | func (e *Epub) Write(destFilePath string) error {
130 |
131 | f, err := os.Create(destFilePath)
132 | if err != nil {
133 | return &UnableToCreateEpubError{
134 | Path: destFilePath,
135 | Err: err,
136 | }
137 | }
138 | defer f.Close()
139 | _, err = e.WriteTo(f)
140 | return err
141 | }
142 |
143 | // Create the EPUB folder structure in a temp directory
144 | func createEpubFolders(rootEpubDir string) {
145 | if err := filesystem.Mkdir(
146 | filepath.Join(
147 | rootEpubDir,
148 | contentFolderName,
149 | ),
150 | dirPermissions); err != nil {
151 | // No reason this should happen if tempDir creation was successful
152 | panic(fmt.Sprintf("Error creating EPUB subdirectory: %s", err))
153 | }
154 |
155 | if err := filesystem.Mkdir(
156 | filepath.Join(
157 | rootEpubDir,
158 | contentFolderName,
159 | xhtmlFolderName,
160 | ),
161 | dirPermissions); err != nil {
162 | panic(fmt.Sprintf("Error creating xhtml subdirectory: %s", err))
163 | }
164 |
165 | if err := filesystem.Mkdir(
166 | filepath.Join(
167 | rootEpubDir,
168 | metaInfFolderName,
169 | ),
170 | dirPermissions); err != nil {
171 | panic(fmt.Sprintf("Error creating META-INF subdirectory: %s", err))
172 | }
173 | }
174 |
175 | // Write the contatiner file (container.xml), which mostly just points to the
176 | // package file (package.opf)
177 | //
178 | // Sample: https://github.com/bmaupin/epub-samples/blob/master/minimal-v3plus2/META-INF/container.xml
179 | // Spec: http://www.idpf.org/epub/301/spec/epub-ocf.html#sec-container-metainf-container.xml
180 | func writeContainerFile(rootEpubDir string) {
181 | containerFilePath := filepath.Join(rootEpubDir, metaInfFolderName, containerFilename)
182 | if err := filesystem.WriteFile(
183 | containerFilePath,
184 | []byte(
185 | fmt.Sprintf(
186 | containerFileTemplate,
187 | contentFolderName,
188 | pkgFilename,
189 | ),
190 | ),
191 | filePermissions,
192 | ); err != nil {
193 | panic(fmt.Sprintf("Error writing container file: %s", err))
194 | }
195 | }
196 |
197 | // Write the CSS files to the temporary directory and add them to the package
198 | // file
199 | func (e *Epub) writeCSSFiles(rootEpubDir string) error {
200 | err := e.writeMedia(rootEpubDir, e.css, CSSFolderName)
201 | if err != nil {
202 | return err
203 | }
204 |
205 | // Clean up the cover temp file if one was created
206 | os.Remove(e.cover.cssTempFile)
207 |
208 | return nil
209 | }
210 |
211 | // writeCounter counts the number of bytes written to it.
212 | type writeCounter struct {
213 | Total int64 // Total # of bytes written
214 | }
215 |
216 | // Write implements the io.Writer interface.
217 | // Always completes and never returns an error.
218 | func (wc *writeCounter) Write(p []byte) (int, error) {
219 | n := len(p)
220 | wc.Total += int64(n)
221 | return n, nil
222 | }
223 |
224 | // Write the EPUB file itself by zipping up everything from a temp directory
225 | // The return value is the number of bytes written. Any error encountered during the write is also returned.
226 | func (e *Epub) writeEpub(rootEpubDir string, dst io.Writer) (int64, error) {
227 | counter := &writeCounter{}
228 | teeWriter := io.MultiWriter(counter, dst)
229 |
230 | z := zip.NewWriter(teeWriter)
231 |
232 | skipMimetypeFile := false
233 |
234 | // addFileToZip adds the file present at path to the zip archive. The path is relative to the rootEpubDir
235 | addFileToZip := func(path string, d fs.DirEntry, err error) error {
236 | if err != nil {
237 | return err
238 | }
239 |
240 | // Get the path of the file relative to the folder we're zipping
241 | relativePath, err := filepath.Rel(rootEpubDir, path)
242 | if err != nil {
243 | // tempDir and path are both internal, so we shouldn't get here
244 | return err
245 | }
246 | relativePath = filepath.ToSlash(relativePath)
247 |
248 | // Only include regular files, not directories
249 | info, err := d.Info()
250 | if err != nil {
251 | return err
252 | }
253 | if !info.Mode().IsRegular() {
254 | return nil
255 | }
256 |
257 | var w io.Writer
258 | if filepath.FromSlash(path) == filepath.Join(rootEpubDir, mimetypeFilename) {
259 | // Skip the mimetype file if it's already been written
260 | if skipMimetypeFile == true {
261 | return nil
262 | }
263 | // The mimetype file must be uncompressed according to the EPUB spec
264 | w, err = z.CreateHeader(&zip.FileHeader{
265 | Name: relativePath,
266 | Method: zip.Store,
267 | })
268 | } else {
269 | w, err = z.Create(relativePath)
270 | }
271 | if err != nil {
272 | return fmt.Errorf("error creating zip writer: %w", err)
273 | }
274 |
275 | r, err := filesystem.Open(path)
276 | if err != nil {
277 | return fmt.Errorf("error opening file %v being added to EPUB: %w", path, err)
278 | }
279 | defer func() {
280 | if err := r.Close(); err != nil {
281 | panic(err)
282 | }
283 | }()
284 |
285 | _, err = io.Copy(w, r)
286 | if err != nil {
287 | return fmt.Errorf("error copying contents of file being added EPUB: %w", err)
288 | }
289 | return nil
290 | }
291 |
292 | // Add the mimetype file first
293 | mimetypeFilePath := filepath.Join(rootEpubDir, mimetypeFilename)
294 | mimetypeInfo, err := fs.Stat(filesystem, mimetypeFilePath)
295 | if err != nil {
296 | if err := z.Close(); err != nil {
297 | panic(err)
298 | }
299 | return counter.Total, fmt.Errorf("unable to get FileInfo for mimetype file: %w", err)
300 | }
301 | err = addFileToZip(mimetypeFilePath, fileInfoToDirEntry(mimetypeInfo), nil)
302 | if err != nil {
303 | if err := z.Close(); err != nil {
304 | panic(err)
305 | }
306 | return counter.Total, fmt.Errorf("unable to add mimetype file to EPUB: %w", err)
307 | }
308 |
309 | skipMimetypeFile = true
310 |
311 | err = fs.WalkDir(filesystem, rootEpubDir, addFileToZip)
312 | if err != nil {
313 | if err := z.Close(); err != nil {
314 | panic(err)
315 | }
316 | return counter.Total, fmt.Errorf("unable to add file to EPUB: %w", err)
317 | }
318 |
319 | err = z.Close()
320 | return counter.Total, err
321 | }
322 |
323 | // Get fonts from their source and save them in the temporary directory
324 | func (e *Epub) writeFonts(rootEpubDir string) error {
325 | return e.writeMedia(rootEpubDir, e.fonts, FontFolderName)
326 | }
327 |
328 | // Get images from their source and save them in the temporary directory
329 | func (e *Epub) writeImages(rootEpubDir string) error {
330 | return e.writeMedia(rootEpubDir, e.images, ImageFolderName)
331 | }
332 |
333 | // Get videos from their source and save them in the temporary directory
334 | func (e *Epub) writeVideos(rootEpubDir string) error {
335 | return e.writeMedia(rootEpubDir, e.videos, VideoFolderName)
336 | }
337 |
338 | // Get media from their source and save them in the temporary directory
339 | func (e *Epub) writeMedia(rootEpubDir string, mediaMap map[string]string, mediaFolderName string) error {
340 | if len(mediaMap) > 0 {
341 | mediaFolderPath := filepath.Join(rootEpubDir, contentFolderName, mediaFolderName)
342 | if err := filesystem.Mkdir(mediaFolderPath, dirPermissions); err != nil {
343 | return fmt.Errorf("unable to create directory: %s", err)
344 | }
345 |
346 | for mediaFilename, mediaSource := range mediaMap {
347 | mediaType, err := grabber{(e.Client)}.fetchMedia(mediaSource, mediaFolderPath, mediaFilename)
348 | if err != nil {
349 | return err
350 | }
351 | // The cover image has a special value for the properties attribute
352 | mediaProperties := ""
353 | if mediaFilename == e.cover.imageFilename {
354 | mediaProperties = coverImageProperties
355 | }
356 |
357 | // Add the file to the OPF manifest
358 | e.pkg.addToManifest(fixXMLId(mediaFilename), filepath.Join(mediaFolderName, mediaFilename), mediaType, mediaProperties)
359 | }
360 | }
361 | return nil
362 | }
363 |
364 | // fixXMLId takes a string and returns an XML id compatible string.
365 | // https://www.w3.org/TR/REC-xml-names/#NT-NCName
366 | // This means it must not contain a colon (:) or whitespace and it must not
367 | // start with a digit, punctuation or diacritics
368 | func fixXMLId(id string) string {
369 | if len(id) == 0 {
370 | panic("No id given")
371 | }
372 | fixedId := []rune{}
373 | for i := 0; len(id) > 0; i++ {
374 | r, size := utf8.DecodeRuneInString(id)
375 | if i == 0 {
376 | // The new id should be prefixed with 'id' if an invalid
377 | // starting character is found
378 | // this is not 100% accurate, but a better check than no check
379 | if unicode.IsNumber(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) {
380 | fixedId = append(fixedId, []rune("id")...)
381 | }
382 | }
383 | if !unicode.IsSpace(r) && r != ':' {
384 | fixedId = append(fixedId, r)
385 | }
386 | id = id[size:]
387 | }
388 | return string(fixedId)
389 | }
390 |
391 | // Write the mimetype file
392 | //
393 | // Sample: https://github.com/bmaupin/epub-samples/blob/master/minimal-v3plus2/mimetype
394 | // Spec: http://www.idpf.org/epub/301/spec/epub-ocf.html#sec-zip-container-mime
395 | func writeMimetype(rootEpubDir string) {
396 | mimetypeFilePath := filepath.Join(rootEpubDir, mimetypeFilename)
397 |
398 | if err := filesystem.WriteFile(mimetypeFilePath, []byte(mediaTypeEpub), filePermissions); err != nil {
399 | panic(fmt.Sprintf("Error writing mimetype file: %s", err))
400 | }
401 | }
402 |
403 | func (e *Epub) writePackageFile(rootEpubDir string) {
404 | e.pkg.write(rootEpubDir)
405 | }
406 |
407 | // Write the section files to the temporary directory and add the sections to
408 | // the TOC and package files
409 | func (e *Epub) writeSections(rootEpubDir string) {
410 | var index int
411 |
412 | if len(e.sections) > 0 {
413 | // If a cover was set, add it to the package spine first so it shows up
414 | // first in the reading order
415 | if e.cover.xhtmlFilename != "" {
416 | e.pkg.addToSpine(e.cover.xhtmlFilename)
417 | }
418 |
419 | for _, section := range e.sections {
420 | // Set the title of the cover page XHTML to the title of the EPUB
421 | if section.filename == e.cover.xhtmlFilename {
422 | section.xhtml.setTitle(e.Title())
423 | }
424 |
425 | sectionFilePath := filepath.Join(rootEpubDir, contentFolderName, xhtmlFolderName, section.filename)
426 | section.xhtml.write(sectionFilePath)
427 | relativePath := filepath.Join(xhtmlFolderName, section.filename)
428 |
429 | // The cover page should have already been added to the spine first
430 | if section.filename != e.cover.xhtmlFilename {
431 | e.pkg.addToSpine(section.filename)
432 | }
433 | e.pkg.addToManifest(section.filename, relativePath, mediaTypeXhtml, "")
434 |
435 | // Don't add pages without titles or the cover to the TOC
436 | if section.xhtml.Title() != "" && section.filename != e.cover.xhtmlFilename {
437 | e.toc.addSection(index, section.xhtml.Title(), relativePath)
438 |
439 | // Add subsections
440 | if section.children != nil {
441 | for _, child := range *section.children {
442 | index += 1
443 | relativeSubPath := filepath.Join(xhtmlFolderName, child.filename)
444 | e.toc.addSubSection(relativePath, index, child.xhtml.Title(), relativeSubPath)
445 |
446 | subSectionFilePath := filepath.Join(rootEpubDir, contentFolderName, xhtmlFolderName, child.filename)
447 | child.xhtml.write(subSectionFilePath)
448 |
449 | // Add subsection to spine
450 | e.pkg.addToSpine(child.filename)
451 | e.pkg.addToManifest(child.filename, relativeSubPath, mediaTypeXhtml, "")
452 | }
453 | }
454 | }
455 |
456 | index += 1
457 | }
458 | }
459 | }
460 |
461 | // Write the TOC file to the temporary directory and add the TOC entries to the
462 | // package file
463 | func (e *Epub) writeToc(rootEpubDir string) {
464 | e.pkg.addToManifest(tocNavItemID, tocNavFilename, mediaTypeXhtml, tocNavItemProperties)
465 | e.pkg.addToManifest(tocNcxItemID, tocNcxFilename, mediaTypeNcx, "")
466 |
467 | e.toc.write(rootEpubDir)
468 | }
469 |
--------------------------------------------------------------------------------
/pkg/epub/epub.go:
--------------------------------------------------------------------------------
1 | /*
2 | Package epub generates valid EPUB 3.0 files with additional EPUB 2.0 table of
3 | contents (as seen here: https://github.com/bmaupin/epub-samples) for maximum
4 | compatibility.
5 |
6 | Basic usage:
7 |
8 | // Create a new EPUB
9 | e := epub.NewEpub("My title")
10 |
11 | // Set the author
12 | e.SetAuthor("Hingle McCringleberry")
13 |
14 | // Add a section
15 | section1Body := `Section 1
16 | This is a paragraph.
`
17 | e.AddSection(section1Body, "Section 1", "", "")
18 |
19 | // Write the EPUB
20 | err = e.Write("My EPUB.epub")
21 | if err != nil {
22 | // handle error
23 | }
24 | */
25 | package epub
26 |
27 | import (
28 | "fmt"
29 | "io/fs"
30 | "net/http"
31 | "os"
32 | "path"
33 | "path/filepath"
34 | "strings"
35 | "sync"
36 |
37 | // TODO: Eventually this should include the major version (e.g. github.com/gofrs/uuid/v3) but that would break
38 | // compatibility with Go < 1.9 (https://github.com/golang/go/wiki/Modules#semantic-import-versioning)
39 | "github.com/gofrs/uuid"
40 | "github.com/vincent-petithory/dataurl"
41 | )
42 |
43 | // FilenameAlreadyUsedError is thrown by AddCSS, AddFont, AddImage, or AddSection
44 | // if the same filename is used more than once.
45 | type FilenameAlreadyUsedError struct {
46 | Filename string // Filename that caused the error
47 | }
48 |
49 | func (e *FilenameAlreadyUsedError) Error() string {
50 | return fmt.Sprintf("Filename already used: %s", e.Filename)
51 | }
52 |
53 | // FileRetrievalError is thrown by AddCSS, AddFont, AddImage, or Write if there was a
54 | // problem retrieving the source file that was provided.
55 | type FileRetrievalError struct {
56 | Source string // The source of the file whose retrieval failed
57 | Err error // The underlying error that was thrown
58 | }
59 |
60 | func (e *FileRetrievalError) Error() string {
61 | return fmt.Sprintf("Error retrieving %q from source: %+v", e.Source, e.Err)
62 | }
63 |
64 | // ParentDoesNotExistError is thrown by AddSubSection if the parent with the
65 | // previously defined internal filename does not exist.
66 | type ParentDoesNotExistError struct {
67 | Filename string // Filename that caused the error
68 | }
69 |
70 | func (e *ParentDoesNotExistError) Error() string {
71 | return fmt.Sprintf("Parent with the internal filename %s does not exist", e.Filename)
72 | }
73 |
74 | // Folder names used for resources inside the EPUB
75 | const (
76 | CSSFolderName = "css"
77 | FontFolderName = "fonts"
78 | ImageFolderName = "images"
79 | VideoFolderName = "videos"
80 | )
81 |
82 | const (
83 | cssFileFormat = "css%04d%s"
84 | defaultCoverBody = ` `
85 | defaultCoverCSSContent = `body {
86 | background-color: #FFFFFF;
87 | margin-bottom: 0px;
88 | margin-left: 0px;
89 | margin-right: 0px;
90 | margin-top: 0px;
91 | text-align: center;
92 | }
93 | img {
94 | max-height: 100%;
95 | max-width: 100%;
96 | }
97 | `
98 | defaultCoverCSSFilename = "cover.css"
99 | defaultCoverCSSSource = "cover.css"
100 | defaultCoverImgFormat = "cover%s"
101 | defaultCoverXhtmlFilename = "cover.xhtml"
102 | defaultEpubLang = "en"
103 | fontFileFormat = "font%04d%s"
104 | imageFileFormat = "image%04d%s"
105 | videoFileFormat = "video%04d%s"
106 | sectionFileFormat = "section%04d.xhtml"
107 | urnUUIDPrefix = "urn:uuid:"
108 | )
109 |
110 | // Epub implements an EPUB file.
111 | type Epub struct {
112 | sync.Mutex
113 | *http.Client
114 | author string
115 | cover *epubCover
116 | // The key is the css filename, the value is the css source
117 | css map[string]string
118 | // The key is the font filename, the value is the font source
119 | fonts map[string]string
120 | identifier string
121 | // The key is the image filename, the value is the image source
122 | images map[string]string
123 | // The key is the video filename, the value is the video source
124 | videos map[string]string
125 | // Language
126 | lang string
127 | // Description
128 | desc string
129 | // Page progression direction
130 | ppd string
131 | // The package file (package.opf)
132 | pkg *pkg
133 | sections []epubSection
134 | title string
135 | // Table of contents
136 | toc *toc
137 | }
138 |
139 | type epubCover struct {
140 | cssFilename string
141 | cssTempFile string
142 | imageFilename string
143 | xhtmlFilename string
144 | }
145 |
146 | type epubSection struct {
147 | filename string
148 | xhtml *xhtml
149 | children *[]epubSection
150 | }
151 |
152 | // NewEpub returns a new Epub.
153 | func NewEpub(title string) *Epub {
154 | e := &Epub{}
155 | e.cover = &epubCover{
156 | cssFilename: "",
157 | cssTempFile: "",
158 | imageFilename: "",
159 | xhtmlFilename: "",
160 | }
161 | e.Client = http.DefaultClient
162 | e.css = make(map[string]string)
163 | e.fonts = make(map[string]string)
164 | e.images = make(map[string]string)
165 | e.videos = make(map[string]string)
166 | e.pkg = newPackage()
167 | e.toc = newToc()
168 | // Set minimal required attributes
169 | e.SetIdentifier(urnUUIDPrefix + uuid.Must(uuid.NewV4()).String())
170 | e.SetLang(defaultEpubLang)
171 | e.SetTitle(title)
172 |
173 | return e
174 | }
175 |
176 | // AddCSS adds a CSS file to the EPUB and returns a relative path to the CSS
177 | // file that can be used in EPUB sections in the format:
178 | // ../CSSFolderName/internalFilename
179 | //
180 | // The CSS source should either be a URL, a path to a local file, or an embedded data URL; in any
181 | // case, the CSS file will be retrieved and stored in the EPUB.
182 | //
183 | // The internal filename will be used when storing the CSS file in the EPUB
184 | // and must be unique among all CSS files. If the same filename is used more
185 | // than once, FilenameAlreadyUsedError will be returned. The internal filename is
186 | // optional; if no filename is provided, one will be generated.
187 | func (e *Epub) AddCSS(source string, internalFilename string) (string, error) {
188 | e.Lock()
189 | defer e.Unlock()
190 | return e.addCSS(source, internalFilename)
191 | }
192 |
193 | func (e *Epub) addCSS(source string, internalFilename string) (string, error) {
194 | return addMedia(e.Client, source, internalFilename, cssFileFormat, CSSFolderName, e.css)
195 | }
196 |
197 | // AddFont adds a font file to the EPUB and returns a relative path to the font
198 | // file that can be used in EPUB sections in the format:
199 | // ../FontFolderName/internalFilename
200 | //
201 | // The font source should either be a URL, a path to a local file, or an embedded data URL; in any
202 | // case, the font file will be retrieved and stored in the EPUB.
203 | //
204 | // The internal filename will be used when storing the font file in the EPUB
205 | // and must be unique among all font files. If the same filename is used more
206 | // than once, FilenameAlreadyUsedError will be returned. The internal filename is
207 | // optional; if no filename is provided, one will be generated.
208 | func (e *Epub) AddFont(source string, internalFilename string) (string, error) {
209 | e.Lock()
210 | defer e.Unlock()
211 | return addMedia(e.Client, source, internalFilename, fontFileFormat, FontFolderName, e.fonts)
212 | }
213 |
214 | // AddImage adds an image to the EPUB and returns a relative path to the image
215 | // file that can be used in EPUB sections in the format:
216 | // ../ImageFolderName/internalFilename
217 | //
218 | // The image source should either be a URL, a path to a local file, or an embedded data URL; in any
219 | // case, the image file will be retrieved and stored in the EPUB.
220 | //
221 | // The internal filename will be used when storing the image file in the EPUB
222 | // and must be unique among all image files. If the same filename is used more
223 | // than once, FilenameAlreadyUsedError will be returned. The internal filename is
224 | // optional; if no filename is provided, one will be generated.
225 | func (e *Epub) AddImage(source string, imageFilename string) (string, error) {
226 | e.Lock()
227 | defer e.Unlock()
228 | return addMedia(e.Client, source, imageFilename, imageFileFormat, ImageFolderName, e.images)
229 | }
230 |
231 | // AddVideo adds an video to the EPUB and returns a relative path to the video
232 | // file that can be used in EPUB sections in the format:
233 | // ../VideoFolderName/internalFilename
234 | //
235 | // The video source should either be a URL, a path to a local file, or an embedded data URL; in any
236 | // case, the video file will be retrieved and stored in the EPUB.
237 | //
238 | // The internal filename will be used when storing the video file in the EPUB
239 | // and must be unique among all video files. If the same filename is used more
240 | // than once, FilenameAlreadyUsedError will be returned. The internal filename is
241 | // optional; if no filename is provided, one will be generated.
242 | func (e *Epub) AddVideo(source string, videoFilename string) (string, error) {
243 | e.Lock()
244 | defer e.Unlock()
245 | return addMedia(e.Client, source, videoFilename, videoFileFormat, VideoFolderName, e.videos)
246 | }
247 |
248 | // AddSection adds a new section (chapter, etc) to the EPUB and returns a
249 | // relative path to the section that can be used from another section (for
250 | // links).
251 | //
252 | // The body must be valid XHTML that will go between the tags of the
253 | // section XHTML file. The content will not be validated.
254 | //
255 | // The title will be used for the table of contents. The section will be shown
256 | // in the table of contents in the same order it was added to the EPUB. The
257 | // title is optional; if no title is provided, the section will not be added to
258 | // the table of contents.
259 | //
260 | // The internal filename will be used when storing the section file in the EPUB
261 | // and must be unique among all section files. If the same filename is used more
262 | // than once, FilenameAlreadyUsedError will be returned. The internal filename is
263 | // optional; if no filename is provided, one will be generated.
264 | //
265 | // The internal path to an already-added CSS file (as returned by AddCSS) to be
266 | // used for the section is optional.
267 | func (e *Epub) AddSection(body string, sectionTitle string, internalFilename string, internalCSSPath string) (string, error) {
268 | e.Lock()
269 | defer e.Unlock()
270 | return e.addSection("", body, sectionTitle, internalFilename, internalCSSPath)
271 | }
272 |
273 | // AddSubSection adds a nested section (chapter, etc) to an existing section.
274 | // The method returns a relative path to the section that can be used from another
275 | // section (for links).
276 | //
277 | // The parent filename must be a valid filename from another section already added.
278 | //
279 | // The body must be valid XHTML that will go between the tags of the
280 | // section XHTML file. The content will not be validated.
281 | //
282 | // The title will be used for the table of contents. The section will be shown
283 | // as a nested entry of the parent section in the table of contents. The
284 | // title is optional; if no title is provided, the section will not be added to
285 | // the table of contents.
286 | //
287 | // The internal filename will be used when storing the section file in the EPUB
288 | // and must be unique among all section files. If the same filename is used more
289 | // than once, FilenameAlreadyUsedError will be returned. The internal filename is
290 | // optional; if no filename is provided, one will be generated.
291 | //
292 | // The internal path to an already-added CSS file (as returned by AddCSS) to be
293 | // used for the section is optional.
294 | func (e *Epub) AddSubSection(parentFilename string, body string, sectionTitle string, internalFilename string, internalCSSPath string) (string, error) {
295 | e.Lock()
296 | defer e.Unlock()
297 | return e.addSection(parentFilename, body, sectionTitle, internalFilename, internalCSSPath)
298 | }
299 |
300 | func (e *Epub) addSection(parentFilename string, body string, sectionTitle string, internalFilename string, internalCSSPath string) (string, error) {
301 | parentIndex := -1
302 |
303 | // Generate a filename if one isn't provided
304 | if internalFilename == "" {
305 | index := 1
306 | for internalFilename == "" {
307 | internalFilename = fmt.Sprintf(sectionFileFormat, index)
308 | for item, section := range e.sections {
309 | if section.filename == parentFilename {
310 | parentIndex = item
311 | }
312 | if section.filename == internalFilename {
313 | internalFilename, index = "", index+1
314 | if parentFilename == "" || parentIndex != -1 {
315 | break
316 | }
317 | }
318 | // Check for nested sections with the same filename to avoid duplicate entries
319 | if section.children != nil {
320 | for _, subsection := range *section.children {
321 | if subsection.filename == internalFilename {
322 | internalFilename, index = "", index+1
323 | }
324 | }
325 | }
326 | }
327 | }
328 | } else {
329 | for item, section := range e.sections {
330 | if section.filename == parentFilename {
331 | parentIndex = item
332 | }
333 | if section.filename == internalFilename {
334 | return "", &FilenameAlreadyUsedError{Filename: internalFilename}
335 | }
336 | if section.children != nil {
337 | for _, subsection := range *section.children {
338 | if subsection.filename == internalFilename {
339 | return "", &FilenameAlreadyUsedError{Filename: internalFilename}
340 | }
341 | }
342 | }
343 | }
344 | }
345 |
346 | if parentFilename != "" && parentIndex == -1 {
347 | return "", &ParentDoesNotExistError{Filename: parentFilename}
348 | }
349 |
350 | x := newXhtml(body)
351 | x.setTitle(sectionTitle)
352 | x.setXmlnsEpub(xmlnsEpub)
353 |
354 | if internalCSSPath != "" {
355 | x.setCSS(internalCSSPath)
356 | }
357 |
358 | s := epubSection{
359 | filename: internalFilename,
360 | xhtml: x,
361 | children: nil,
362 | }
363 |
364 | if parentIndex != -1 {
365 | if e.sections[parentIndex].children == nil {
366 | var section []epubSection
367 | e.sections[parentIndex].children = §ion
368 | }
369 | (*e.sections[parentIndex].children) = append(*e.sections[parentIndex].children, s)
370 | } else {
371 | e.sections = append(e.sections, s)
372 | }
373 |
374 | return internalFilename, nil
375 | }
376 |
377 | // Author returns the author of the EPUB.
378 | func (e *Epub) Author() string {
379 | return e.author
380 | }
381 |
382 | // Identifier returns the unique identifier of the EPUB.
383 | func (e *Epub) Identifier() string {
384 | return e.identifier
385 | }
386 |
387 | // Lang returns the language of the EPUB.
388 | func (e *Epub) Lang() string {
389 | return e.lang
390 | }
391 |
392 | // Description returns the description of the EPUB.
393 | func (e *Epub) Description() string {
394 | return e.desc
395 | }
396 |
397 | // Ppd returns the page progression direction of the EPUB.
398 | func (e *Epub) Ppd() string {
399 | return e.ppd
400 | }
401 |
402 | // SetAuthor sets the author of the EPUB.
403 | func (e *Epub) SetAuthor(author string) {
404 | e.Lock()
405 | defer e.Unlock()
406 | e.author = author
407 | e.pkg.setAuthor(author)
408 | }
409 |
410 | // SetCover sets the cover page for the EPUB using the provided image source and
411 | // optional CSS.
412 | //
413 | // The internal path to an already-added image file (as returned by AddImage) is
414 | // required.
415 | //
416 | // The internal path to an already-added CSS file (as returned by AddCSS) to be
417 | // used for the cover is optional. If the CSS path isn't provided, default CSS
418 | // will be used.
419 | func (e *Epub) SetCover(internalImagePath string, internalCSSPath string) {
420 | e.Lock()
421 | defer e.Unlock()
422 | // If a cover already exists
423 | if e.cover.xhtmlFilename != "" {
424 | // Remove the xhtml file
425 | for i, section := range e.sections {
426 | if section.filename == e.cover.xhtmlFilename {
427 | e.sections = append(e.sections[:i], e.sections[i+1:]...)
428 | break
429 | }
430 | }
431 |
432 | // Remove the image
433 | delete(e.images, e.cover.imageFilename)
434 |
435 | // Remove the CSS
436 | delete(e.css, e.cover.cssFilename)
437 |
438 | if e.cover.cssTempFile != "" {
439 | os.Remove(e.cover.cssTempFile)
440 | }
441 | }
442 |
443 | e.cover.imageFilename = filepath.Base(internalImagePath)
444 | e.pkg.setCover(e.cover.imageFilename)
445 |
446 | // Use default cover stylesheet if one isn't provided
447 | if internalCSSPath == "" {
448 | // Encode the default CSS
449 | e.cover.cssTempFile = dataurl.EncodeBytes([]byte(defaultCoverCSSContent))
450 | var err error
451 | internalCSSPath, err = e.addCSS(e.cover.cssTempFile, defaultCoverCSSFilename)
452 | // If that doesn't work, generate a filename
453 | if _, ok := err.(*FilenameAlreadyUsedError); ok {
454 | coverCSSFilename := fmt.Sprintf(
455 | cssFileFormat,
456 | len(e.css)+1,
457 | ".css",
458 | )
459 |
460 | internalCSSPath, err = e.addCSS(e.cover.cssTempFile, coverCSSFilename)
461 | if _, ok := err.(*FilenameAlreadyUsedError); ok {
462 | // This shouldn't cause an error
463 | panic(fmt.Sprintf("Error adding default cover CSS file: %s", err))
464 | }
465 | }
466 | if err != nil {
467 | if _, ok := err.(*FilenameAlreadyUsedError); !ok {
468 | panic(fmt.Sprintf("DEBUG %+v", err))
469 | }
470 | }
471 | }
472 | e.cover.cssFilename = filepath.Base(internalCSSPath)
473 |
474 | coverBody := fmt.Sprintf(defaultCoverBody, internalImagePath)
475 | // Title won't be used since the cover won't be added to the TOC
476 | // First try to use the default cover filename
477 | coverPath, err := e.addSection("", coverBody, "", defaultCoverXhtmlFilename, internalCSSPath)
478 | // If that doesn't work, generate a filename
479 | if _, ok := err.(*FilenameAlreadyUsedError); ok {
480 | coverPath, err = e.addSection("", coverBody, "", "", internalCSSPath)
481 | if _, ok := err.(*FilenameAlreadyUsedError); ok {
482 | // This shouldn't cause an error since we're not specifying a filename
483 | panic(fmt.Sprintf("Error adding default cover XHTML file: %s", err))
484 | }
485 | }
486 | e.cover.xhtmlFilename = filepath.Base(coverPath)
487 | }
488 |
489 | // SetIdentifier sets the unique identifier of the EPUB, such as a UUID, DOI,
490 | // ISBN or ISSN. If no identifier is set, a UUID will be automatically
491 | // generated.
492 | func (e *Epub) SetIdentifier(identifier string) {
493 | e.Lock()
494 | defer e.Unlock()
495 | e.identifier = identifier
496 | e.pkg.setIdentifier(identifier)
497 | e.toc.setIdentifier(identifier)
498 | }
499 |
500 | // SetLang sets the language of the EPUB.
501 | func (e *Epub) SetLang(lang string) {
502 | e.Lock()
503 | defer e.Unlock()
504 | e.lang = lang
505 | e.pkg.setLang(lang)
506 | }
507 |
508 | // SetDescription sets the description of the EPUB.
509 | func (e *Epub) SetDescription(desc string) {
510 | e.Lock()
511 | defer e.Unlock()
512 | e.desc = desc
513 | e.pkg.setDescription(desc)
514 | }
515 |
516 | // SetPpd sets the page progression direction of the EPUB.
517 | func (e *Epub) SetPpd(direction string) {
518 | e.Lock()
519 | defer e.Unlock()
520 | e.ppd = direction
521 | e.pkg.setPpd(direction)
522 | }
523 |
524 | // SetTitle sets the title of the EPUB.
525 | func (e *Epub) SetTitle(title string) {
526 | e.Lock()
527 | defer e.Unlock()
528 | e.title = title
529 | e.pkg.setTitle(title)
530 | e.toc.setTitle(title)
531 | }
532 |
533 | // Title returns the title of the EPUB.
534 | func (e *Epub) Title() string {
535 | return e.title
536 | }
537 |
538 | // Add a media file to the EPUB and return the path relative to the EPUB section
539 | // files
540 | func addMedia(client *http.Client, source string, internalFilename string, mediaFileFormat string, mediaFolderName string, mediaMap map[string]string) (string, error) {
541 | err := grabber{client}.checkMedia(source)
542 | if err != nil {
543 | return "", &FileRetrievalError{
544 | Source: source,
545 | Err: err,
546 | }
547 | }
548 | if internalFilename == "" {
549 | // If a filename isn't provided, use the filename from the source
550 | internalFilename = filepath.Base(source)
551 | _, ok := mediaMap[internalFilename]
552 | // if filename is too long, invalid or already used, try to generate a unique filename
553 | if len(internalFilename) > 255 || !fs.ValidPath(internalFilename) || ok {
554 | internalFilename = fmt.Sprintf(
555 | mediaFileFormat,
556 | len(mediaMap)+1,
557 | strings.ToLower(filepath.Ext(source)),
558 | )
559 | }
560 | }
561 |
562 | if _, ok := mediaMap[internalFilename]; ok {
563 | return "", &FilenameAlreadyUsedError{Filename: internalFilename}
564 | }
565 |
566 | mediaMap[internalFilename] = source
567 |
568 | return path.Join(
569 | "..",
570 | mediaFolderName,
571 | internalFilename,
572 | ), nil
573 | }
574 |
--------------------------------------------------------------------------------
/pkg/progressbar/progressbar.go:
--------------------------------------------------------------------------------
1 | package progressbar
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "github.com/mattn/go-runewidth"
7 | "github.com/mitchellh/colorstring"
8 | "golang.org/x/crypto/ssh/terminal"
9 | "io"
10 | "log"
11 | "math"
12 | "os"
13 | "regexp"
14 | "strings"
15 | "sync"
16 | "time"
17 | )
18 |
19 | // ProgressBar is a thread-safe, simple
20 | // progress bar
21 | type ProgressBar struct {
22 | state state
23 | config configProgressbar
24 | lock sync.Mutex
25 | }
26 |
27 | // State is the basic properties of the bar
28 | type State struct {
29 | CurrentPercent float64
30 | CurrentBytes float64
31 | SecondsSince float64
32 | SecondsLeft float64
33 | KBsPerSecond float64
34 | }
35 |
36 | type state struct {
37 | currentNum int64
38 | currentPercent int
39 | lastPercent int
40 | currentSaucerSize int
41 | isAltSaucerHead bool
42 |
43 | lastShown time.Time
44 | startTime time.Time
45 |
46 | counterTime time.Time
47 | counterNumSinceLast int64
48 | counterLastTenRates []float64
49 |
50 | maxLineWidth int
51 | currentBytes float64
52 | finished bool
53 |
54 | rendered string
55 | }
56 |
57 | type configProgressbar struct {
58 | max int64 // max number of the counter
59 | maxHumanized string
60 | maxHumanizedSuffix string
61 | width int
62 | writer io.Writer
63 | theme Theme
64 | renderWithBlankState bool
65 | description string
66 | iterationString string
67 | ignoreLength bool // ignoreLength if max bytes not known
68 |
69 | // whether the output is expected to contain color codes
70 | colorCodes bool
71 |
72 | // show rate of change in kB/sec or MB/sec
73 | showBytes bool
74 | // show the iterations per second
75 | showIterationsPerSecond bool
76 | showIterationsCount bool
77 |
78 | // whether the progress bar should attempt to predict the finishing
79 | // time of the progress based on the start time and the average
80 | // number of seconds between increments.
81 | predictTime bool
82 |
83 | // minimum time to wait in between updates
84 | throttleDuration time.Duration
85 |
86 | // clear bar once finished
87 | clearOnFinish bool
88 |
89 | // spinnerType should be a number between 0-75
90 | spinnerType int
91 |
92 | // fullWidth specifies whether to measure and set the bar to a specific width
93 | fullWidth bool
94 |
95 | // invisible doesn't render the bar at all, useful for debugging
96 | invisible bool
97 |
98 | onCompletion func()
99 |
100 | // whether the render function should make use of ANSI codes to reduce console I/O
101 | useANSICodes bool
102 | }
103 |
104 | // Theme defines the elements of the bar
105 | type Theme struct {
106 | Saucer string
107 | AltSaucerHead string
108 | SaucerHead string
109 | SaucerPadding string
110 | BarStart string
111 | BarEnd string
112 | }
113 |
114 | // Option is the type all options need to adhere to
115 | type Option func(p *ProgressBar)
116 |
117 | var defaultTheme = Theme{Saucer: "█", SaucerPadding: " ", BarStart: "|", BarEnd: "|"}
118 |
119 | // NewOptions constructs a new instance of ProgressBar, with any options you specify
120 | func NewOptions(max int, options ...Option) *ProgressBar {
121 | return NewOptions64(int64(max), options...)
122 | }
123 |
124 | // NewOptions64 constructs a new instance of ProgressBar, with any options you specify
125 | func NewOptions64(max int64, options ...Option) *ProgressBar {
126 | b := ProgressBar{
127 | state: getBasicState(),
128 | config: configProgressbar{
129 | writer: os.Stdout,
130 | theme: defaultTheme,
131 | iterationString: "it",
132 | width: 40,
133 | max: max,
134 | throttleDuration: 0 * time.Nanosecond,
135 | predictTime: true,
136 | spinnerType: 9,
137 | invisible: false,
138 | },
139 | }
140 |
141 | for _, o := range options {
142 | o(&b)
143 | }
144 |
145 | if b.config.spinnerType < 0 || b.config.spinnerType > 75 {
146 | panic("invalid spinner type, must be between 0 and 75")
147 | }
148 |
149 | // ignoreLength if max bytes not known
150 | if b.config.max == -1 {
151 | b.config.ignoreLength = true
152 | b.config.max = int64(b.config.width)
153 | b.config.predictTime = false
154 | }
155 |
156 | b.config.maxHumanized, b.config.maxHumanizedSuffix = humanizeBytes(float64(b.config.max))
157 |
158 | if b.config.renderWithBlankState {
159 |
160 | if err := b.RenderBlank(); err != nil {
161 | log.Println(err)
162 | }
163 | }
164 |
165 | return &b
166 | }
167 |
168 | func getBasicState() state {
169 | now := time.Now()
170 | return state{
171 | startTime: now,
172 | lastShown: now,
173 | counterTime: now,
174 | }
175 | }
176 |
177 | // New returns a new ProgressBar
178 | // with the specified maximum
179 | func New(max int) *ProgressBar {
180 | return NewOptions(max)
181 | }
182 |
183 | // String returns the current rendered version of the progress bar.
184 | // It will never return an empty string while the progress bar is running.
185 | func (p *ProgressBar) String() string {
186 | return p.state.rendered
187 | }
188 |
189 | // RenderBlank renders the current bar state, you can use this to render a 0% state
190 | func (p *ProgressBar) RenderBlank() error {
191 | if p.config.invisible {
192 | return nil
193 | }
194 | return p.render()
195 | }
196 |
197 | // Reset will reset the clock that is used
198 | // to calculate current time and the time left.
199 | func (p *ProgressBar) Reset() {
200 | p.lock.Lock()
201 | defer p.lock.Unlock()
202 |
203 | p.state = getBasicState()
204 | }
205 |
206 | // Finish will fill the bar to full
207 | func (p *ProgressBar) Finish() error {
208 | p.lock.Lock()
209 | p.state.currentNum = p.config.max
210 | p.lock.Unlock()
211 | return p.Add(0)
212 | }
213 |
214 | // Add will add the specified amount to the progressbar
215 | func (p *ProgressBar) Add(num int) error {
216 | return p.Add64(int64(num))
217 | }
218 |
219 | // Set wil set the bar to a current number
220 | func (p *ProgressBar) Set(num int) error {
221 | return p.Set64(int64(num))
222 | }
223 |
224 | // Set64 wil set the bar to a current number
225 | func (p *ProgressBar) Set64(num int64) error {
226 | p.lock.Lock()
227 | toAdd := num - int64(p.state.currentBytes)
228 | p.lock.Unlock()
229 | return p.Add64(toAdd)
230 | }
231 |
232 | // Add64 will add the specified amount to the progressbar
233 | func (p *ProgressBar) Add64(num int64) error {
234 | if p.config.invisible {
235 | return nil
236 | }
237 | p.lock.Lock()
238 | defer p.lock.Unlock()
239 |
240 | if p.config.max == 0 {
241 | return errors.New("max must be greater than 0")
242 | }
243 |
244 | if p.state.currentNum < p.config.max {
245 | if p.config.ignoreLength {
246 | p.state.currentNum = (p.state.currentNum + num) % p.config.max
247 | } else {
248 | p.state.currentNum += num
249 | }
250 | }
251 |
252 | p.state.currentBytes += float64(num)
253 |
254 | // reset the countdown timer every second to take rolling average
255 | p.state.counterNumSinceLast += num
256 | if time.Since(p.state.counterTime).Seconds() > 0.5 {
257 | p.state.counterLastTenRates = append(p.state.counterLastTenRates, float64(p.state.counterNumSinceLast)/time.Since(p.state.counterTime).Seconds())
258 | if len(p.state.counterLastTenRates) > 10 {
259 | p.state.counterLastTenRates = p.state.counterLastTenRates[1:]
260 | }
261 | p.state.counterTime = time.Now()
262 | p.state.counterNumSinceLast = 0
263 | }
264 |
265 | percent := float64(p.state.currentNum) / float64(p.config.max)
266 | p.state.currentSaucerSize = int(percent * float64(p.config.width))
267 | p.state.currentPercent = int(percent * 100)
268 | updateBar := p.state.currentPercent != p.state.lastPercent && p.state.currentPercent > 0
269 |
270 | p.state.lastPercent = p.state.currentPercent
271 | if p.state.currentNum > p.config.max {
272 | return errors.New("current number exceeds max")
273 | }
274 |
275 | // always update if show bytes/second or its/second
276 | if updateBar || p.config.showIterationsPerSecond || p.config.showIterationsCount {
277 | return p.render()
278 | }
279 |
280 | return nil
281 | }
282 |
283 | // Clear erases the progress bar from the current line
284 | func (p *ProgressBar) Clear() error {
285 | return clearProgressBar(p.config, p.state)
286 | }
287 |
288 | // Describe will change the description shown before the progress, which
289 | // can be changed on the fly (as for a slow running process).
290 | func (p *ProgressBar) Describe(description string) {
291 | p.config.description = description
292 | if err := p.RenderBlank(); err != nil {
293 | log.Println(err)
294 | }
295 | }
296 |
297 | // GetMax returns the max of a bar
298 | func (p *ProgressBar) GetMax() int {
299 | return int(p.config.max)
300 | }
301 |
302 | // GetMax64 returns the current max
303 | func (p *ProgressBar) GetMax64() int64 {
304 | return p.config.max
305 | }
306 |
307 | // ChangeMax takes in int
308 | // and changes the max value
309 | // of the progress bar
310 | func (p *ProgressBar) ChangeMax(newMax int) {
311 | p.ChangeMax64(int64(newMax))
312 | }
313 |
314 | // ChangeMax64 is basically
315 | // the same as ChangeMax,
316 | // but takes in int64
317 | // to avoid casting
318 | func (p *ProgressBar) ChangeMax64(newMax int64) {
319 | p.config.max = newMax
320 |
321 | if p.config.showBytes {
322 | p.config.maxHumanized, p.config.maxHumanizedSuffix = humanizeBytes(float64(p.config.max))
323 | }
324 |
325 | if err := p.Add(0); err != nil { // re-render
326 | log.Println(err)
327 | }
328 | }
329 |
330 | // IsFinished returns true if progress bar is completed
331 | func (p *ProgressBar) IsFinished() bool {
332 | return p.state.finished
333 | }
334 |
335 | // render renders the progress bar, updating the maximum
336 | // rendered line width. this function is not thread-safe,
337 | // so it must be called with an acquired lock.
338 | func (p *ProgressBar) render() error {
339 | // make sure that the rendering is not happening too quickly
340 | // but always show if the currentNum reaches the max
341 | if time.Since(p.state.lastShown).Nanoseconds() < p.config.throttleDuration.Nanoseconds() &&
342 | p.state.currentNum < p.config.max {
343 | return nil
344 | }
345 |
346 | if !p.config.useANSICodes {
347 | // first, clear the existing progress bar
348 | err := clearProgressBar(p.config, p.state)
349 | if err != nil {
350 | return err
351 | }
352 | }
353 |
354 | // check if the progress bar is finished
355 | if !p.state.finished && p.state.currentNum >= p.config.max {
356 | p.state.finished = true
357 | if !p.config.clearOnFinish {
358 | if _, err := renderProgressBar(p.config, &p.state); err != nil {
359 | log.Println(err)
360 | }
361 | }
362 |
363 | if p.config.onCompletion != nil {
364 | p.config.onCompletion()
365 | }
366 | }
367 | if p.state.finished {
368 | // when using ANSI codes we don't pre-clean the current line
369 | if p.config.useANSICodes {
370 | err := clearProgressBar(p.config, p.state)
371 | if err != nil {
372 | return err
373 | }
374 | }
375 | return nil
376 | }
377 |
378 | // then, re-render the current progress bar
379 | w, err := renderProgressBar(p.config, &p.state)
380 | if err != nil {
381 | return err
382 | }
383 |
384 | if w > p.state.maxLineWidth {
385 | p.state.maxLineWidth = w
386 | }
387 |
388 | p.state.lastShown = time.Now()
389 |
390 | return nil
391 | }
392 |
393 | // State returns the current state
394 | func (p *ProgressBar) State() State {
395 | p.lock.Lock()
396 | defer p.lock.Unlock()
397 | s := State{}
398 | s.CurrentPercent = float64(p.state.currentNum) / float64(p.config.max)
399 | s.CurrentBytes = p.state.currentBytes
400 | s.SecondsSince = time.Since(p.state.startTime).Seconds()
401 | if p.state.currentNum > 0 {
402 | s.SecondsLeft = s.SecondsSince / float64(p.state.currentNum) * (float64(p.config.max) - float64(p.state.currentNum))
403 | }
404 | s.KBsPerSecond = float64(p.state.currentBytes) / 1024.0 / s.SecondsSince
405 | return s
406 | }
407 |
408 | // regex matching ansi escape codes
409 | var ansiRegex = regexp.MustCompile(`\x1b\[[\d;]*[a-zA-Z]`)
410 |
411 | func getStringWidth(c configProgressbar, str string) int {
412 | if c.colorCodes {
413 | // convert any color codes in the progress bar into the respective ANSI codes
414 | str = colorstring.Color(str)
415 | }
416 |
417 | // the width of the string, if printed to the console
418 | // does not include the carriage return character
419 | cleanString := strings.ReplaceAll(str, "\r", "")
420 |
421 | if c.colorCodes {
422 | // the ANSI codes for the colors do not take up space in the console output,
423 | // so they do not count towards the output string width
424 | cleanString = ansiRegex.ReplaceAllString(cleanString, "")
425 | }
426 |
427 | // get the amount of runes in the string instead of the
428 | // character count of the string, as some runes span multiple characters.
429 | // see https://stackoverflow.com/a/12668840/2733724
430 | stringWidth := runewidth.StringWidth(cleanString)
431 | return stringWidth
432 | }
433 |
434 | func renderProgressBar(c configProgressbar, s *state) (int, error) {
435 | leftBrac := ""
436 | rightBrac := ""
437 | saucer := ""
438 | saucerHead := ""
439 | bytesString := ""
440 | str := ""
441 |
442 | averageRate := average(s.counterLastTenRates)
443 | if len(s.counterLastTenRates) == 0 || s.finished {
444 | // if no average samples, or if finished,
445 | // then average rate should be the total rate
446 | averageRate = s.currentBytes / time.Since(s.startTime).Seconds()
447 | }
448 |
449 | // show iteration count in "current/total" iterations format
450 | if c.showIterationsCount {
451 | bytesString += "("
452 | if !c.ignoreLength {
453 | if c.showBytes {
454 | currentHumanize, currentSuffix := humanizeBytes(s.currentBytes)
455 | if currentSuffix == c.maxHumanizedSuffix {
456 | bytesString += fmt.Sprintf("%s/%s%s", currentHumanize, c.maxHumanized, c.maxHumanizedSuffix)
457 | } else {
458 | bytesString += fmt.Sprintf("%s%s/%s%s", currentHumanize, currentSuffix, c.maxHumanized, c.maxHumanizedSuffix)
459 | }
460 | } else {
461 | bytesString += fmt.Sprintf("%.0f/%d", s.currentBytes, c.max)
462 | }
463 | } else {
464 | if c.showBytes {
465 | currentHumanize, currentSuffix := humanizeBytes(s.currentBytes)
466 | bytesString += fmt.Sprintf("%s%s", currentHumanize, currentSuffix)
467 | } else {
468 | bytesString += fmt.Sprintf("%.0f/%s", s.currentBytes, "-")
469 | }
470 | }
471 | }
472 |
473 | // show rolling average rate in kB/sec or MB/sec
474 | if c.showBytes {
475 | if bytesString == "" {
476 | bytesString += "("
477 | } else {
478 | bytesString += ", "
479 | }
480 | kbPerSecond := averageRate / 1024.0
481 | if kbPerSecond > 1024.0 {
482 | bytesString += fmt.Sprintf("%0.3f MB/s", kbPerSecond/1024.0)
483 | } else if kbPerSecond > 0 {
484 | bytesString += fmt.Sprintf("%0.3f kB/s", kbPerSecond)
485 | }
486 | }
487 |
488 | // show iterations rate
489 | if c.showIterationsPerSecond {
490 | if bytesString == "" {
491 | bytesString += "("
492 | } else {
493 | bytesString += ", "
494 | }
495 | if averageRate > 1 {
496 | bytesString += fmt.Sprintf("%0.0f %s/s", averageRate, c.iterationString)
497 | } else {
498 | bytesString += fmt.Sprintf("%0.0f %s/min", 60*averageRate, c.iterationString)
499 | }
500 | }
501 | if bytesString != "" {
502 | bytesString += ")"
503 | }
504 |
505 | // show time prediction in "current/total" seconds format
506 | if c.predictTime {
507 | leftBrac = (time.Duration(time.Since(s.startTime).Seconds()) * time.Second).String()
508 | rightBracNum := time.Duration((1/averageRate)*(float64(c.max)-float64(s.currentNum))) * time.Second
509 | if rightBracNum.Seconds() < 0 {
510 | rightBracNum = 0 * time.Second
511 | }
512 | rightBrac = rightBracNum.String()
513 | }
514 |
515 | if c.fullWidth && !c.ignoreLength {
516 | width, _, err := terminal.GetSize(int(os.Stdout.Fd()))
517 | if err != nil {
518 | width, _, err = terminal.GetSize(int(os.Stderr.Fd()))
519 | if err != nil {
520 | width = 80
521 | }
522 | }
523 |
524 | c.width = width - getStringWidth(c, c.description) - 14 - len(bytesString) - len(leftBrac) - len(rightBrac)
525 | s.currentSaucerSize = int(float64(s.currentPercent) / 100.0 * float64(c.width))
526 | }
527 | if s.currentSaucerSize > 0 {
528 | if c.ignoreLength {
529 | saucer = strings.Repeat(c.theme.SaucerPadding, s.currentSaucerSize-1)
530 | } else {
531 | saucer = strings.Repeat(c.theme.Saucer, s.currentSaucerSize-1)
532 | }
533 |
534 | // Check if an alternate saucer head is set for animation
535 | if c.theme.AltSaucerHead != "" && s.isAltSaucerHead {
536 | saucerHead = c.theme.AltSaucerHead
537 | s.isAltSaucerHead = false
538 | } else if c.theme.SaucerHead == "" || s.currentSaucerSize == c.width {
539 | // use the saucer for the saucer head if it hasn't been set
540 | // to preserve backwards compatibility
541 | saucerHead = c.theme.Saucer
542 | } else {
543 | saucerHead = c.theme.SaucerHead
544 | s.isAltSaucerHead = true
545 | }
546 | saucer += saucerHead
547 | }
548 |
549 | /*
550 | Progress Bar format
551 | Description % |------ | (kb/s) (iteration count) (iteration rate) (predict time)
552 | */
553 | repeatAmount := c.width - s.currentSaucerSize
554 | if repeatAmount < 0 {
555 | repeatAmount = 0
556 | }
557 | if c.ignoreLength {
558 | str = fmt.Sprintf("\r%s %s %s ",
559 | spinners[c.spinnerType][int(math.Round(math.Mod(float64(time.Since(s.startTime).Milliseconds()/100), float64(len(spinners[c.spinnerType])))))],
560 | c.description,
561 | bytesString,
562 | )
563 | } else if leftBrac == "" {
564 | str = fmt.Sprintf("\r%s%4d%% %s%s%s%s %s ",
565 | c.description,
566 | s.currentPercent,
567 | c.theme.BarStart,
568 | saucer,
569 | strings.Repeat(c.theme.SaucerPadding, repeatAmount),
570 | c.theme.BarEnd,
571 | bytesString,
572 | )
573 | } else {
574 | if s.currentPercent == 100 {
575 | str = fmt.Sprintf("\r%s%4d%% %s%s%s%s %s",
576 | c.description,
577 | s.currentPercent,
578 | c.theme.BarStart,
579 | saucer,
580 | strings.Repeat(c.theme.SaucerPadding, repeatAmount),
581 | c.theme.BarEnd,
582 | bytesString,
583 | )
584 | } else {
585 | str = fmt.Sprintf("\r%s%s%s%s%s %s%4d%% ",
586 | c.description,
587 | c.theme.BarStart,
588 | saucer,
589 | strings.Repeat(c.theme.SaucerPadding, repeatAmount),
590 | c.theme.BarEnd,
591 | bytesString,
592 | s.currentPercent,
593 | )
594 | }
595 | }
596 |
597 | if c.colorCodes {
598 | // convert any color codes in the progress bar into the respective ANSI codes
599 | str = colorstring.Color(str)
600 | }
601 |
602 | s.rendered = str
603 |
604 | return getStringWidth(c, str), writeString(c, str)
605 | }
606 |
607 | func clearProgressBar(c configProgressbar, s state) error {
608 | if c.useANSICodes {
609 | // write the "clear current line" ANSI escape sequence
610 | return writeString(c, "\033[2K\r")
611 | }
612 | // fill the empty content
613 | // to overwrite the progress bar and jump
614 | // back to the beginning of the line
615 | str := fmt.Sprintf("\r%s\r", strings.Repeat(" ", s.maxLineWidth))
616 | return writeString(c, str)
617 | // the following does not show correctly if the previous line is longer than subsequent line
618 | // return writeString(c, "\r")
619 | }
620 |
621 | func writeString(c configProgressbar, str string) error {
622 | if _, err := io.WriteString(c.writer, str); err != nil {
623 | return err
624 | }
625 |
626 | if f, ok := c.writer.(*os.File); ok {
627 | // ignore any errors in Sync(), as stdout
628 | // can't be synced on some operating systems
629 | // like Debian 9 (Stretch)
630 | if err := f.Sync(); err != nil {
631 | }
632 | }
633 |
634 | return nil
635 | }
636 |
637 | // Reader is the progressbar io.Reader struct
638 | type Reader struct {
639 | io.Reader
640 | bar *ProgressBar
641 | }
642 |
643 | // Read will read the data and add the number of bytes to the progressbar
644 | func (r *Reader) Read(p []byte) (n int, err error) {
645 | n, err = r.Reader.Read(p)
646 | if err = r.bar.Add(n); err != nil {
647 | log.Println(err)
648 | }
649 | return
650 | }
651 |
652 | // Close the reader when it implements io.Closer
653 | func (r *Reader) Close() (err error) {
654 | if closer, ok := r.Reader.(io.Closer); ok {
655 | return closer.Close()
656 | }
657 | if err = r.bar.Finish(); err != nil {
658 | log.Println(err)
659 | }
660 | return
661 | }
662 |
663 | // Write implement io.Writer
664 | func (p *ProgressBar) Write(b []byte) (n int, err error) {
665 | n = len(b)
666 | if err = p.Add(n); err != nil {
667 | log.Println(err)
668 | }
669 | return
670 | }
671 |
672 | // Read implement io.Reader
673 | func (p *ProgressBar) Read(b []byte) (n int, err error) {
674 | n = len(b)
675 | if err = p.Add(n); err != nil {
676 | log.Println(err)
677 | }
678 | return
679 | }
680 |
681 | func (p *ProgressBar) Close() (err error) {
682 | if err = p.Finish(); err != nil {
683 | log.Println(err)
684 | }
685 | return
686 | }
687 |
688 | func average(xs []float64) float64 {
689 | total := 0.0
690 | for _, v := range xs {
691 | total += v
692 | }
693 | return total / float64(len(xs))
694 | }
695 |
696 | func humanizeBytes(s float64) (string, string) {
697 | sizes := []string{" B", " kB", " MB", " GB", " TB", " PB", " EB"}
698 | base := 1024.0
699 | if s < 10 {
700 | return fmt.Sprintf("%2.0f", s), "B"
701 | }
702 | e := math.Floor(logn(s, base))
703 | suffix := sizes[int(e)]
704 | val := math.Floor(s/math.Pow(base, e)*10+0.5) / 10
705 | f := "%.0f"
706 | if val < 10 {
707 | f = "%.1f"
708 | }
709 |
710 | return fmt.Sprintf(f, val), suffix
711 | }
712 |
713 | func logn(n, b float64) float64 {
714 | return math.Log(n) / math.Log(b)
715 | }
716 |
717 | var spinners = map[int][]string{
718 | 0: {"←", "↖", "↑", "↗", "→", "↘", "↓", "↙"},
719 | 1: {"▁", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▁"},
720 | 2: {"▖", "▘", "▝", "▗"},
721 | 3: {"┤", "┘", "┴", "└", "├", "┌", "┬", "┐"},
722 | 4: {"◢", "◣", "◤", "◥"},
723 | 5: {"◰", "◳", "◲", "◱"},
724 | 6: {"◴", "◷", "◶", "◵"},
725 | 7: {"◐", "◓", "◑", "◒"},
726 | 8: {".", "o", "O", "@", "*"},
727 | 9: {"|", "/", "-", "\\"},
728 | 10: {"◡◡", "⊙⊙", "◠◠"},
729 | 11: {"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"},
730 | 12: {">))'>", " >))'>", " >))'>", " >))'>", " >))'>", " <'((<", " <'((<", " <'((<"},
731 | 13: {"⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"},
732 | 14: {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
733 | 15: {"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"},
734 | 16: {"▉", "▊", "▋", "▌", "▍", "▎", "▏", "▎", "▍", "▌", "▋", "▊", "▉"},
735 | 17: {"■", "□", "▪", "▫"},
736 | 18: {"←", "↑", "→", "↓"},
737 | 19: {"╫", "╪"},
738 | 20: {"⇐", "⇖", "⇑", "⇗", "⇒", "⇘", "⇓", "⇙"},
739 | 21: {"⠁", "⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠠", "⠠", "⠤", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈", "⠈"},
740 | 22: {"⠈", "⠉", "⠋", "⠓", "⠒", "⠐", "⠐", "⠒", "⠖", "⠦", "⠤", "⠠", "⠠", "⠤", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈"},
741 | 23: {"⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠴", "⠲", "⠒", "⠂", "⠂", "⠒", "⠚", "⠙", "⠉", "⠁"},
742 | 24: {"⠋", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋"},
743 | 25: {"ヲ", "ァ", "ィ", "ゥ", "ェ", "ォ", "ャ", "ュ", "ョ", "ッ", "ア", "イ", "ウ", "エ", "オ", "カ", "キ", "ク", "ケ", "コ", "サ", "シ", "ス", "セ", "ソ", "タ", "チ", "ツ", "テ", "ト", "ナ", "ニ", "ヌ", "ネ", "ノ", "ハ", "ヒ", "フ", "ヘ", "ホ", "マ", "ミ", "ム", "メ", "モ", "ヤ", "ユ", "ヨ", "ラ", "リ", "ル", "レ", "ロ", "ワ", "ン"},
744 | 26: {".", "..", "..."},
745 | 27: {"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▉", "▊", "▋", "▌", "▍", "▎", "▏", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁"},
746 | 28: {".", "o", "O", "°", "O", "o", "."},
747 | 29: {"+", "x"},
748 | 30: {"v", "<", "^", ">"},
749 | 31: {">>--->", " >>--->", " >>--->", " >>--->", " >>--->", " <---<<", " <---<<", " <---<<", " <---<<", "<---<<"},
750 | 32: {"|", "||", "|||", "||||", "|||||", "|||||||", "||||||||", "|||||||", "||||||", "|||||", "||||", "|||", "||", "|"},
751 | 33: {"[ ]", "[= ]", "[== ]", "[=== ]", "[==== ]", "[===== ]", "[====== ]", "[======= ]", "[======== ]", "[========= ]", "[==========]"},
752 | 34: {"(*---------)", "(-*--------)", "(--*-------)", "(---*------)", "(----*-----)", "(-----*----)", "(------*---)", "(-------*--)", "(--------*-)", "(---------*)"},
753 | 35: {"█▒▒▒▒▒▒▒▒▒", "███▒▒▒▒▒▒▒", "█████▒▒▒▒▒", "███████▒▒▒", "██████████"},
754 | 36: {"[ ]", "[=> ]", "[===> ]", "[=====> ]", "[======> ]", "[========> ]", "[==========> ]", "[============> ]", "[==============> ]", "[================> ]", "[==================> ]", "[===================>]"},
755 | 37: {"ဝ", "၀"},
756 | 38: {"▌", "▀", "▐▄"},
757 | 39: {"🌍", "🌎", "🌏"},
758 | 40: {"◜", "◝", "◞", "◟"},
759 | 41: {"⬒", "⬔", "⬓", "⬕"},
760 | 42: {"⬖", "⬘", "⬗", "⬙"},
761 | 43: {"[>>> >]", "[]>>>> []", "[] >>>> []", "[] >>>> []", "[] >>>> []", "[] >>>>[]", "[>> >>]"},
762 | 44: {"♠", "♣", "♥", "♦"},
763 | 45: {"➞", "➟", "➠", "➡", "➠", "➟"},
764 | 46: {" | ", ` \ `, "_ ", ` \ `, " | ", " / ", " _", " / "},
765 | 47: {" . . . .", ". . . .", ". . . .", ". . . .", ". . . . ", ". . . . ."},
766 | 48: {" | ", " / ", " _ ", ` \ `, " | ", ` \ `, " _ ", " / "},
767 | 49: {"⎺", "⎻", "⎼", "⎽", "⎼", "⎻"},
768 | 50: {"▹▹▹▹▹", "▸▹▹▹▹", "▹▸▹▹▹", "▹▹▸▹▹", "▹▹▹▸▹", "▹▹▹▹▸"},
769 | 51: {"[ ]", "[ =]", "[ ==]", "[ ===]", "[====]", "[=== ]", "[== ]", "[= ]"},
770 | 52: {"( ● )", "( ● )", "( ● )", "( ● )", "( ●)", "( ● )", "( ● )", "( ● )", "( ● )"},
771 | 53: {"✶", "✸", "✹", "✺", "✹", "✷"},
772 | 54: {"▐|\\____________▌", "▐_|\\___________▌", "▐__|\\__________▌", "▐___|\\_________▌", "▐____|\\________▌", "▐_____|\\_______▌", "▐______|\\______▌", "▐_______|\\_____▌", "▐________|\\____▌", "▐_________|\\___▌", "▐__________|\\__▌", "▐___________|\\_▌", "▐____________|\\▌", "▐____________/|▌", "▐___________/|_▌", "▐__________/|__▌", "▐_________/|___▌", "▐________/|____▌", "▐_______/|_____▌", "▐______/|______▌", "▐_____/|_______▌", "▐____/|________▌", "▐___/|_________▌", "▐__/|__________▌", "▐_/|___________▌", "▐/|____________▌"},
773 | 55: {"▐⠂ ▌", "▐⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂▌", "▐ ⠠▌", "▐ ⡀▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐⠠ ▌"},
774 | 56: {"¿", "?"},
775 | 57: {"⢹", "⢺", "⢼", "⣸", "⣇", "⡧", "⡗", "⡏"},
776 | 58: {"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"},
777 | 59: {". ", ".. ", "...", " ..", " .", " "},
778 | 60: {".", "o", "O", "°", "O", "o", "."},
779 | 61: {"▓", "▒", "░"},
780 | 62: {"▌", "▀", "▐", "▄"},
781 | 63: {"⊶", "⊷"},
782 | 64: {"▪", "▫"},
783 | 65: {"□", "■"},
784 | 66: {"▮", "▯"},
785 | 67: {"-", "=", "≡"},
786 | 68: {"d", "q", "p", "b"},
787 | 69: {"∙∙∙", "●∙∙", "∙●∙", "∙∙●", "∙∙∙"},
788 | 70: {"🌑 ", "🌒 ", "🌓 ", "🌔 ", "🌕 ", "🌖 ", "🌗 ", "🌘 "},
789 | 71: {"☗", "☖"},
790 | 72: {"⧇", "⧆"},
791 | 73: {"◉", "◎"},
792 | 74: {"㊂", "㊀", "㊁"},
793 | 75: {"⦾", "⦿"},
794 | }
795 |
--------------------------------------------------------------------------------