├── 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 | Noa Himesaka 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 | Noa Himesaka 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 | Noa Himesaka 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 | JetBrains 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 | 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 = `Cover Image` 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 | --------------------------------------------------------------------------------