mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
1333 lines
41 KiB
Go
1333 lines
41 KiB
Go
// 本地书籍导入与阅读(对应 legado 的本地 TXT / EPUB 书)。
|
||
//
|
||
// 存储:正文统一落到 data/reader/local/<bookID>.<txt|epub>,目录信息与网络书
|
||
// 一样存 reader_chapters,用 ReaderChapter.Tag 记住定位信息:
|
||
// - TXT:UTF-8 规范化后文件内的字节区间 "start:end"(读章节只读这段,不载整本)
|
||
// - EPUB:zip 内的 XHTML 条目路径(每次按需解压该条)
|
||
// 网络书籍不受影响:ReaderBook.LocalPath 为空即走书源链路。
|
||
package reader
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"html"
|
||
"io"
|
||
"io/fs"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/truewhile/MeBox/internal/model"
|
||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||
)
|
||
|
||
// LocalBookMaxBytes 单个本地书籍文件大小上限。
|
||
const LocalBookMaxBytes = 64 << 20
|
||
|
||
// localBooksDirName data 目录下的存放位置。
|
||
const localBooksDirName = "local"
|
||
|
||
// txtChapterPattern TXT 目录正则(对齐 legado 默认 TXT 目录规则:RE2 不支持
|
||
// lookbehind,改成行首锚定):
|
||
//
|
||
// 第 1 章 / 第1章 / 第十二节 / 序章 / 楔子 / 番外 ...
|
||
//
|
||
// 分成「章标记」+「章名」两段捕获,后面再看章名像不像句子。
|
||
var txtChapterPattern = regexp.MustCompile(
|
||
`(?m)^[ \t ]{0,4}(序章|楔子|正文|终章|后记|尾声|番外|第[ \t]{0,4}[0-9〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,12}[ \t]{0,4}[章节卷集部篇])([^\n]{0,40})$`)
|
||
|
||
// txtSentenceTail 章名段命中这些,基本可断定这行是正文而不是章名
|
||
// (例如「第一章的正文内容。」)。
|
||
var txtSentenceTail = regexp.MustCompile(`^[的的是了在有和与就都也还很最]|[。!?;:]`)
|
||
|
||
// looksLikeChapterTitle 判断匹配到的一行是不是真的章名。
|
||
func looksLikeChapterTitle(marker, rest string) bool {
|
||
if marker == "" {
|
||
return false
|
||
}
|
||
if txtSentenceTail.MatchString(strings.TrimSpace(rest)) {
|
||
return false
|
||
}
|
||
// 章名一般不短于标记本身太多,留够「第1章 开端(上)」这类
|
||
return len([]rune(marker+rest)) <= 40
|
||
}
|
||
|
||
// txtChapter 章节在规范化后 UTF-8 文本里的字节区间(End 不含)。
|
||
type txtChapter struct {
|
||
Title string
|
||
Start, End int
|
||
}
|
||
|
||
// epubChapter EPUB 里的一章:zip 条目路径 + 标题。
|
||
type epubChapter struct {
|
||
Title string
|
||
Href string
|
||
}
|
||
|
||
// localBooksDir 本地书籍存放目录(不存在则创建)。
|
||
func (s *ReaderService) localBooksDir() (string, error) {
|
||
dir := filepath.Join(s.cfg.App.DataDir, "reader", localBooksDirName)
|
||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||
return "", fmt.Errorf("创建本地书籍目录失败: %w", err)
|
||
}
|
||
return dir, nil
|
||
}
|
||
|
||
// localFilePath 某本本地书籍的落盘路径。
|
||
//
|
||
// LocalExternal 为真时 LocalPath 是服务器上的绝对路径(原地引用,导入时由
|
||
// 管理员选定);否则是 data/reader/local 下的托管副本文件名。
|
||
func (s *ReaderService) localFilePath(book *model.ReaderBook) (string, error) {
|
||
if book.LocalPath == "" {
|
||
return "", fmt.Errorf("不是本地书籍")
|
||
}
|
||
if book.LocalExternal {
|
||
return filepath.Clean(book.LocalPath), nil
|
||
}
|
||
dir, err := s.localBooksDir()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// 托管副本的文件名只由服务端生成(<id>.<ext>),这里再挡一次路径穿越
|
||
return filepath.Join(dir, filepath.Base(book.LocalPath)), nil
|
||
}
|
||
|
||
// localBookPayload 本地书籍的解析结果(上传与「从服务器路径导入」共用)。
|
||
type localBookPayload struct {
|
||
title string
|
||
ext string
|
||
charset string
|
||
chaps []model.ReaderChapter
|
||
coverEntry string
|
||
// payload 原始字节,仅托管副本模式需要落盘
|
||
payload []byte
|
||
}
|
||
|
||
// parseLocalBookPayload 按内容识别 TXT / EPUB 并切好目录。
|
||
func parseLocalBookPayload(name string, data []byte) (*localBookPayload, error) {
|
||
isEPUB := bytes.HasPrefix(data, []byte("PK\x03\x04"))
|
||
if !isEPUB && !strings.EqualFold(filepath.Ext(name), ".epub") && !strings.EqualFold(filepath.Ext(name), ".txt") {
|
||
return nil, fmt.Errorf("仅支持 TXT / EPUB 文件")
|
||
}
|
||
out := &localBookPayload{title: strings.TrimSuffix(name, filepath.Ext(name))}
|
||
if isEPUB {
|
||
list, cover, err := parseEPUB(data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(list) == 0 {
|
||
return nil, fmt.Errorf("EPUB 里没有可读章节")
|
||
}
|
||
out.ext, out.coverEntry = ".epub", cover
|
||
out.chaps = make([]model.ReaderChapter, 0, len(list))
|
||
for i, c := range list {
|
||
out.chaps = append(out.chaps, model.ReaderChapter{Index: i, Title: c.Title, Tag: c.Href})
|
||
}
|
||
return out, nil
|
||
}
|
||
decoded, charset := decodeTextFile(data)
|
||
if strings.TrimSpace(decoded) == "" {
|
||
return nil, fmt.Errorf("文件内容为空或无法解码")
|
||
}
|
||
out.ext, out.charset = ".txt", charset
|
||
for i, c := range splitTXTChapters(decoded) {
|
||
out.chaps = append(out.chaps, model.ReaderChapter{
|
||
Index: i, Title: c.Title, Tag: fmt.Sprintf("%d:%d", c.Start, c.End),
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// localImportSpec 本地书籍落库参数。payload 非空走托管副本(写入
|
||
// data/reader/local);externalPath 非空走原地引用(LocalPath 直接存它)。
|
||
type localImportSpec struct {
|
||
key string // FindBookByURL 的去重键(存进 BookURL)
|
||
title string
|
||
ext string
|
||
charset string
|
||
bookType int
|
||
chaps []model.ReaderChapter
|
||
payload []byte
|
||
externalPath string
|
||
coverEntry string
|
||
}
|
||
|
||
// importLocalBook 本地书籍落库共同路径:去重 → 建/更新 → 落内容 → 写目录。
|
||
//
|
||
// 重复导入按“覆盖更新”处理:章节数没变就保留阅读进度,变了则退回第一章。
|
||
func (s *ReaderService) importLocalBook(
|
||
ctx context.Context, userID string, spec localImportSpec,
|
||
) (*model.ReaderBook, error) {
|
||
if len(spec.chaps) == 0 {
|
||
return nil, fmt.Errorf("未解析出任何章节")
|
||
}
|
||
book, err := s.repo.FindBookByURL(ctx, userID, "", spec.key)
|
||
if err != nil || book == nil {
|
||
book = &model.ReaderBook{}
|
||
}
|
||
isNew := book.ID == ""
|
||
if isNew {
|
||
book.UserID = userID
|
||
book.OriginName = "本地导入"
|
||
book.Type = spec.bookType
|
||
}
|
||
book.Name = spec.title
|
||
book.BookURL = spec.key
|
||
book.Charset = spec.charset
|
||
book.TotalChapterNum = len(spec.chaps)
|
||
book.IsLocal = true
|
||
|
||
managed := spec.externalPath == ""
|
||
if managed {
|
||
book.LocalExternal = false
|
||
book.LocalPath = "placeholder" + spec.ext // 真正的文件名在写入后按 ID 生成
|
||
} else {
|
||
if len(spec.externalPath) > 255 {
|
||
return nil, fmt.Errorf("路径过长,无法保存引用")
|
||
}
|
||
book.LocalExternal = true
|
||
book.LocalPath = filepath.Clean(spec.externalPath)
|
||
}
|
||
|
||
if isNew {
|
||
if err := s.repo.CreateBook(ctx, book); err != nil {
|
||
return nil, fmt.Errorf("创建本地书籍失败: %w", err)
|
||
}
|
||
}
|
||
if managed {
|
||
// 落盘(文件名用 ID,避免同名文件互相覆盖)
|
||
book.LocalPath = book.ID + spec.ext
|
||
path, err := s.localFilePath(book)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.WriteFile(path, spec.payload, 0o640); err != nil {
|
||
return nil, fmt.Errorf("保存书籍文件失败: %w", err)
|
||
}
|
||
}
|
||
// EPUB 封面:从 zip 里解析出的封面图转成签名资源地址,供书架/阅读页展示。
|
||
// 覆盖导入时按新文件重置,避免留下指向已不存在条目的旧封面。
|
||
if spec.ext == ".epub" {
|
||
book.CoverURL = ""
|
||
if spec.coverEntry != "" {
|
||
book.CoverURL = s.LocalAssetURL(book.ID, spec.coverEntry)
|
||
}
|
||
}
|
||
|
||
if !isNew {
|
||
// 覆盖更新:章节数不变就保留进度,否则退回第一章
|
||
if old, err := s.repo.ListChapters(ctx, book.ID); err == nil && len(old) != len(spec.chaps) {
|
||
book.DurChapterIndex = 0
|
||
book.DurChapterPos = 0
|
||
book.DurChapterTitle = ""
|
||
}
|
||
}
|
||
chaps := spec.chaps
|
||
for i := range chaps {
|
||
chaps[i].BookID = book.ID
|
||
}
|
||
if err := s.repo.ReplaceChapters(ctx, book.ID, chaps); err != nil {
|
||
return nil, fmt.Errorf("写入目录失败: %w", err)
|
||
}
|
||
if err := s.repo.UpdateBook(ctx, book); err != nil {
|
||
return nil, fmt.Errorf("更新书籍失败: %w", err)
|
||
}
|
||
book.IsLocal = true
|
||
return book, nil
|
||
}
|
||
|
||
// ImportLocalBook 导入一本本地书籍(TXT / EPUB),内容复制进 data/reader/local。
|
||
//
|
||
// 同名(同 user + 文件名)重复导入按“覆盖更新”处理。
|
||
func (s *ReaderService) ImportLocalBook(
|
||
ctx context.Context, userID, filename string, data []byte,
|
||
) (*model.ReaderBook, error) {
|
||
if len(data) == 0 {
|
||
return nil, fmt.Errorf("上传内容为空")
|
||
}
|
||
if len(data) > LocalBookMaxBytes {
|
||
return nil, fmt.Errorf("文件超过 %dMB", LocalBookMaxBytes>>20)
|
||
}
|
||
name := strings.TrimSpace(filepath.Base(strings.ReplaceAll(filename, "\\", "/")))
|
||
if name == "" || name == "." {
|
||
return nil, fmt.Errorf("缺少文件名")
|
||
}
|
||
parsed, err := parseLocalBookPayload(name, data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.importLocalBook(ctx, userID, localImportSpec{
|
||
key: name,
|
||
title: parsed.title,
|
||
ext: parsed.ext,
|
||
charset: parsed.charset,
|
||
chaps: parsed.chaps,
|
||
payload: data,
|
||
coverEntry: parsed.coverEntry,
|
||
})
|
||
}
|
||
|
||
// ImportLocalBookFromPath 从服务器上已有的文件导入书籍(TXT / EPUB)。
|
||
//
|
||
// 原地引用,不复制到 data/reader/local;同一个文件重复导入按覆盖更新处理,
|
||
// 以绝对路径作为去重键(不同目录下的同名文件互不干扰)。
|
||
func (s *ReaderService) ImportLocalBookFromPath(
|
||
ctx context.Context, userID, path string,
|
||
) (*model.ReaderBook, error) {
|
||
abs, err := filepath.Abs(strings.TrimSpace(path))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("路径无效: %w", err)
|
||
}
|
||
info, err := os.Stat(abs)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("文件不存在或不可读")
|
||
}
|
||
if info.IsDir() {
|
||
return nil, fmt.Errorf("请选择文件而不是目录")
|
||
}
|
||
if info.Size() > LocalBookMaxBytes {
|
||
return nil, fmt.Errorf("文件超过 %dMB", LocalBookMaxBytes>>20)
|
||
}
|
||
f, err := os.Open(abs) // #nosec G304 -- 路径由管理员通过文件选择器选定,handler 已校验允许根目录
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取文件失败: %w", err)
|
||
}
|
||
defer f.Close()
|
||
data, err := io.ReadAll(io.LimitReader(f, LocalBookMaxBytes+1))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取文件失败: %w", err)
|
||
}
|
||
name := filepath.Base(abs)
|
||
parsed, err := parseLocalBookPayload(name, data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.importLocalBook(ctx, userID, localImportSpec{
|
||
key: abs,
|
||
title: parsed.title,
|
||
ext: parsed.ext,
|
||
charset: parsed.charset,
|
||
chaps: parsed.chaps,
|
||
externalPath: abs,
|
||
coverEntry: parsed.coverEntry,
|
||
})
|
||
}
|
||
|
||
// audioExtensions 有声书识别的音频后缀;.strm 是播放指针,单独处理。
|
||
var audioExtensions = map[string]bool{
|
||
".mp3": true, ".m4a": true, ".m4b": true, ".aac": true, ".flac": true,
|
||
".wav": true, ".ogg": true, ".oga": true, ".opus": true, ".wma": true, ".ape": true,
|
||
}
|
||
|
||
// ImportLocalAudioDir 把一个服务器目录导入为一本有声书。
|
||
//
|
||
// 目录(含子目录)下的音频文件与 .strm 指针按相对路径排序后逐个成为章节,
|
||
// 原地引用不复制;重复导入同一个目录按覆盖更新处理。
|
||
func (s *ReaderService) ImportLocalAudioDir(
|
||
ctx context.Context, userID, dir string,
|
||
) (*model.ReaderBook, error) {
|
||
abs, err := filepath.Abs(strings.TrimSpace(dir))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("路径无效: %w", err)
|
||
}
|
||
info, err := os.Stat(abs)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("目录不存在或不可读")
|
||
}
|
||
if !info.IsDir() {
|
||
return nil, fmt.Errorf("请选择目录")
|
||
}
|
||
|
||
type audioEntry struct {
|
||
path string
|
||
rel string
|
||
}
|
||
var tracks []audioEntry
|
||
walkErr := filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error {
|
||
if err != nil {
|
||
return nil // 单个条目不可读不影响整体
|
||
}
|
||
if strings.HasPrefix(d.Name(), ".") && p != abs {
|
||
if d.IsDir() {
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
ext := strings.ToLower(filepath.Ext(d.Name()))
|
||
if !audioExtensions[ext] && ext != ".strm" {
|
||
return nil
|
||
}
|
||
rel, relErr := filepath.Rel(abs, p)
|
||
if relErr != nil {
|
||
rel = d.Name()
|
||
}
|
||
tracks = append(tracks, audioEntry{path: p, rel: rel})
|
||
return nil
|
||
})
|
||
if walkErr != nil {
|
||
return nil, fmt.Errorf("扫描目录失败: %w", walkErr)
|
||
}
|
||
if len(tracks) == 0 {
|
||
return nil, fmt.Errorf("目录里没有找到音频文件(支持 .strm 播放指针)")
|
||
}
|
||
sort.Slice(tracks, func(i, j int) bool { return tracks[i].rel < tracks[j].rel })
|
||
|
||
chaps := make([]model.ReaderChapter, 0, len(tracks))
|
||
for i, t := range tracks {
|
||
chaps = append(chaps, model.ReaderChapter{
|
||
Index: i,
|
||
Title: strings.TrimSuffix(filepath.Base(t.rel), filepath.Ext(t.rel)),
|
||
Tag: t.path,
|
||
})
|
||
}
|
||
return s.importLocalBook(ctx, userID, localImportSpec{
|
||
key: abs,
|
||
title: filepath.Base(abs),
|
||
bookType: 1, // 音频
|
||
chaps: chaps,
|
||
externalPath: abs,
|
||
})
|
||
}
|
||
|
||
// OpenLocalAudio 打开本地音频文件供流式下发(支持 Range,可拖动进度)。
|
||
func (s *ReaderService) OpenLocalAudio(path string) (*os.File, os.FileInfo, error) {
|
||
f, err := os.Open(filepath.Clean(path)) // #nosec G304 -- 路径由签名校验通过
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("音频文件已丢失")
|
||
}
|
||
info, err := f.Stat()
|
||
if err != nil || info.IsDir() {
|
||
f.Close()
|
||
return nil, nil, fmt.Errorf("音频文件不可用")
|
||
}
|
||
return f, info, nil
|
||
}
|
||
|
||
// BackfillLocalCover 给历史导入的本地 EPUB 补上封面地址。
|
||
//
|
||
// 封面是导入时解析出来落库的,早期版本没这一步;在书架加载时自愈一次,
|
||
// 用户就不必为了拿到封面而重新导入。返回是否写回了数据库。
|
||
func (s *ReaderService) BackfillLocalCover(ctx context.Context, book *model.ReaderBook) bool {
|
||
if book == nil || book.CoverURL != "" || book.LocalPath == "" {
|
||
return false
|
||
}
|
||
if !strings.EqualFold(filepath.Ext(book.LocalPath), ".epub") {
|
||
return false
|
||
}
|
||
path, err := s.localFilePath(book)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
f, err := os.Open(path) // #nosec G304 -- path 由服务端按书籍 ID 生成
|
||
if err != nil {
|
||
return false
|
||
}
|
||
defer f.Close()
|
||
st, err := f.Stat()
|
||
if err != nil {
|
||
return false
|
||
}
|
||
zr, err := zip.NewReader(f, st.Size())
|
||
if err != nil {
|
||
return false
|
||
}
|
||
entry := newEPUBZipReader(zr.File).coverEntry()
|
||
if entry == "" {
|
||
return false
|
||
}
|
||
book.CoverURL = s.LocalAssetURL(book.ID, entry)
|
||
if err := s.repo.UpdateBook(ctx, book); err != nil {
|
||
if s.log != nil {
|
||
s.log.Warn("reader: 回填本地书籍封面失败",
|
||
zap.String("book", book.ID), zap.Error(err))
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// LocalChapterContent 读本地书籍的某一章正文(不做书源抓取)。
|
||
func (s *ReaderService) LocalChapterContent(
|
||
ctx context.Context, userID, bookID string, chapterIndex int,
|
||
) (*ChapterContent, error) {
|
||
book, err := s.repo.GetBook(ctx, bookID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if book.UserID != userID {
|
||
return nil, fmt.Errorf("无权操作他人书架")
|
||
}
|
||
if book.LocalPath == "" {
|
||
return nil, fmt.Errorf("不是本地书籍")
|
||
}
|
||
chapters, err := s.repo.ListChapters(ctx, bookID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if chapterIndex < 0 || chapterIndex >= len(chapters) {
|
||
return nil, fmt.Errorf("章节序号越界(共 %d 章)", len(chapters))
|
||
}
|
||
ch := chapters[chapterIndex]
|
||
// 本地有声书:一章 = 一个音频文件或一个 .strm 播放指针
|
||
if book.Type == 1 {
|
||
track, transcoding, err := s.localAudioTrack(book, ch)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &ChapterContent{
|
||
Type: "audio", Tracks: []string{track}, Transcoding: transcoding, declaredType: -1,
|
||
}, nil
|
||
}
|
||
text, err := s.readLocalChapter(book, ch)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// EPUB 里的图片换成签名地址(前端按 [img] 标记渲染)
|
||
text = s.rewriteLocalImages(book.ID, text)
|
||
text = s.applyUserReplaceRules(ctx, userID, book.Name, text)
|
||
return &ChapterContent{Type: "text", Content: text, declaredType: -1}, nil
|
||
}
|
||
|
||
// localAudioTrack 把本地有声书的一章解成可播放的签名地址:
|
||
// .strm 指针解析成远端地址(走媒体代理)或本地文件(走本地音频端点);
|
||
// 浏览器解不了的格式(WMA 等)改走转码端点。第二个返回值表示该章需要转码。
|
||
func (s *ReaderService) localAudioTrack(book *model.ReaderBook, ch model.ReaderChapter) (string, bool, error) {
|
||
target := strings.TrimSpace(ch.Tag)
|
||
if target == "" {
|
||
return "", false, fmt.Errorf("章节定位信息损坏,请重新导入该书")
|
||
}
|
||
if strings.EqualFold(filepath.Ext(target), ".strm") {
|
||
raw, err := readSTRMFile(target)
|
||
if err != nil {
|
||
return "", false, fmt.Errorf("读取 .strm 失败: %w", err)
|
||
}
|
||
if raw == "" {
|
||
return "", false, fmt.Errorf("该 .strm 里没有可用的播放地址")
|
||
}
|
||
// 本地路径:相对 .strm 所在目录解析
|
||
target = raw
|
||
if !isRemoteMediaURL(target) && !filepath.IsAbs(target) {
|
||
target = filepath.Join(filepath.Dir(ch.Tag), target)
|
||
}
|
||
}
|
||
if needsAudioTranscode(target) {
|
||
if s.ffmpegBinary() == "" {
|
||
return "", false, transcodeMissingFFmpegError(target)
|
||
}
|
||
return s.AudioTranscodeURL(book.ID, target), true, nil
|
||
}
|
||
if isRemoteMediaURL(target) {
|
||
return s.ProxyURL(book.ID, target), false, nil
|
||
}
|
||
return s.LocalAudioURL(book.ID, filepath.Clean(target)), false, nil
|
||
}
|
||
|
||
// readSTRMFile 读取 .strm 指针文件里的第一条有效内容(约定为一行播放地址)。
|
||
func readSTRMFile(path string) (string, error) {
|
||
data, err := os.ReadFile(path) // #nosec G304 -- 路径来自管理员导入时选定的目录
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
candidate := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
|
||
if candidate == "" || strings.HasPrefix(candidate, "#") {
|
||
continue
|
||
}
|
||
return candidate, nil
|
||
}
|
||
return "", nil
|
||
}
|
||
|
||
// isRemoteMediaURL 判断 .strm 内容是不是远端地址(http/网盘协议或本机播放端点);
|
||
// 不是的话按同目录下的本地文件路径处理。
|
||
func isRemoteMediaURL(raw string) bool {
|
||
raw = strings.TrimSpace(raw)
|
||
if strings.HasPrefix(raw, "/api/") || strings.HasPrefix(raw, "/Videos/") || strings.HasPrefix(raw, "/videos/") {
|
||
return true
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
switch strings.ToLower(u.Scheme) {
|
||
case "http", "https", "webdav", "davs", "alist", "alists", "openlist", "openlists":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// readLocalChapter 取一章正文:TXT 读字节区间,EPUB 解压对应条目再转纯文本。
|
||
func (s *ReaderService) readLocalChapter(book *model.ReaderBook, ch model.ReaderChapter) (string, error) {
|
||
path, err := s.localFilePath(book)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if strings.HasSuffix(book.LocalPath, ".epub") {
|
||
return readEPUBEntry(path, ch.Tag)
|
||
}
|
||
start, end, ok := parseByteRange(ch.Tag)
|
||
if !ok {
|
||
return "", fmt.Errorf("章节定位信息损坏,请重新导入该书")
|
||
}
|
||
f, err := os.Open(path) // #nosec G304 -- path 由服务端按书籍 ID 生成
|
||
if err != nil {
|
||
return "", fmt.Errorf("本地书籍文件已丢失: %w", err)
|
||
}
|
||
defer f.Close()
|
||
if end <= start {
|
||
return "", nil
|
||
}
|
||
buf := make([]byte, end-start)
|
||
if _, err := f.ReadAt(buf, int64(start)); err != nil && err != io.EOF {
|
||
return "", fmt.Errorf("读取章节失败: %w", err)
|
||
}
|
||
return strings.TrimSpace(string(buf)), nil
|
||
}
|
||
|
||
// DeleteLocalBookFile 移出书架时删除本地文件(网络书籍无文件,直接返回)。
|
||
//
|
||
// 原地引用的书指向服务器上已有的文件/目录,移出书架只解除引用,不动源文件。
|
||
func (s *ReaderService) DeleteLocalBookFile(book *model.ReaderBook) {
|
||
if book == nil || book.LocalPath == "" || book.LocalExternal {
|
||
return
|
||
}
|
||
path, err := s.localFilePath(book)
|
||
if err != nil {
|
||
return
|
||
}
|
||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) && s.log != nil {
|
||
s.log.Warn("reader: 删除本地书籍文件失败",
|
||
zap.String("book", book.ID), zap.Error(err))
|
||
}
|
||
}
|
||
|
||
// parseByteRange 解析 "start:end"。
|
||
func parseByteRange(tag string) (int, int, bool) {
|
||
parts := strings.SplitN(tag, ":", 2)
|
||
if len(parts) != 2 {
|
||
return 0, 0, false
|
||
}
|
||
var start, end int
|
||
if _, err := fmt.Sscanf(parts[0], "%d", &start); err != nil {
|
||
return 0, 0, false
|
||
}
|
||
if _, err := fmt.Sscanf(parts[1], "%d", &end); err != nil {
|
||
return 0, 0, false
|
||
}
|
||
if start < 0 || end < start {
|
||
return 0, 0, false
|
||
}
|
||
return start, end, true
|
||
}
|
||
|
||
// ─── TXT ──────────────────────────────────────────────────────────────────
|
||
|
||
// decodeTextFile 识别编码并统一转成 UTF-8 字符串,返回检测到的字符集名。
|
||
func decodeTextFile(data []byte) (string, string) {
|
||
// UTF-8 BOM
|
||
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
|
||
return string(data[3:]), "utf-8"
|
||
}
|
||
// UTF-16 BOM
|
||
if len(data) >= 2 {
|
||
if data[0] == 0xFF && data[1] == 0xFE {
|
||
if s, err := rule.DecodeBytes(data, "utf-16le"); err == nil {
|
||
return strings.TrimPrefix(s, "\uFEFF"), "utf-16le"
|
||
}
|
||
}
|
||
if data[0] == 0xFE && data[1] == 0xFF {
|
||
if s, err := rule.DecodeBytes(data, "utf-16be"); err == nil {
|
||
return strings.TrimPrefix(s, "\uFEFF"), "utf-16be"
|
||
}
|
||
}
|
||
}
|
||
if utf8.Valid(data) {
|
||
return string(data), "utf-8"
|
||
}
|
||
// 中文小说最常见的另一种编码
|
||
if s, err := rule.DecodeBytes(data, "gbk"); err == nil && utf8.ValidString(s) {
|
||
return s, "gbk"
|
||
}
|
||
if s, err := rule.DecodeBytes(data, "big5"); err == nil && utf8.ValidString(s) {
|
||
return s, "big5"
|
||
}
|
||
return string(data), "utf-8"
|
||
}
|
||
|
||
// splitTXTChapters 按目录正则切章;切不出来就整本当一章。
|
||
// 用字节下标而不是 rune 下标,方便直接按区间读文件。
|
||
func splitTXTChapters(text string) []txtChapter {
|
||
matches := txtChapterPattern.FindAllStringSubmatchIndex(text, -1)
|
||
var starts []int
|
||
var titles []string
|
||
for _, m := range matches {
|
||
// m: [整行起, 整行止, 标记起, 标记止, 章名起, 章名止]
|
||
marker := text[m[2]:m[3]]
|
||
rest := text[m[4]:m[5]]
|
||
if !looksLikeChapterTitle(marker, rest) {
|
||
continue
|
||
}
|
||
starts = append(starts, m[0])
|
||
titles = append(titles, strings.TrimSpace(text[m[0]:m[1]]))
|
||
}
|
||
if len(starts) == 0 {
|
||
return []txtChapter{{Title: "全文", Start: 0, End: len(text)}}
|
||
}
|
||
out := make([]txtChapter, 0, len(starts))
|
||
for i, start := range starts {
|
||
end := len(text)
|
||
if i+1 < len(starts) {
|
||
end = starts[i+1]
|
||
}
|
||
// 章前内容(书名、作者、简介)跟着第一章
|
||
if i == 0 {
|
||
start = 0
|
||
}
|
||
out = append(out, txtChapter{Title: titles[i], Start: start, End: end})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ─── EPUB ─────────────────────────────────────────────────────────────────
|
||
|
||
// parseEPUB 读 EPUB 的 OPF spine,得到有序章节;标题优先取 NCX/NAV 目录,其次取
|
||
// 正文里的首个标题标签,最后退回“第 N 章”。同时返回封面图在 zip 里的条目路径
|
||
// (取不到返回空串)。
|
||
func parseEPUB(data []byte) ([]epubChapter, string, error) {
|
||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("EPUB 损坏(不是有效的 zip): %w", err)
|
||
}
|
||
return parseEPUBReader(newEPUBZipReader(zr.File))
|
||
}
|
||
|
||
// epubZipReader 已打开 zip 的条目索引,章节解析与封面回填共用。
|
||
type epubZipReader struct {
|
||
entries map[string]*zip.File
|
||
}
|
||
|
||
func newEPUBZipReader(files []*zip.File) *epubZipReader {
|
||
entries := make(map[string]*zip.File, len(files))
|
||
for _, f := range files {
|
||
entries[filepath.Clean(f.Name)] = f
|
||
}
|
||
return &epubZipReader{entries: entries}
|
||
}
|
||
|
||
func (r *epubZipReader) read(name string) ([]byte, error) {
|
||
f, ok := r.entries[filepath.Clean(name)]
|
||
if !ok {
|
||
return nil, fmt.Errorf("EPUB 缺少条目 %s", name)
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rc.Close()
|
||
return io.ReadAll(io.LimitReader(rc, LocalBookMaxBytes))
|
||
}
|
||
|
||
// coverEntry 解析 OPF 里的封面声明,返回封面图在 zip 里的条目路径(取不到为空)。
|
||
func (r *epubZipReader) coverEntry() string {
|
||
opfPath, err := epubOPFPath(r.read)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
opfRaw, err := r.read(opfPath)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
opf, err := parseOPF(opfRaw)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return epubCoverEntry(r.entries, filepath.Dir(opfPath), opf.coverHref)
|
||
}
|
||
|
||
// parseEPUBReader 从已打开的 zip 解析章节与封面。
|
||
func parseEPUBReader(er *epubZipReader) ([]epubChapter, string, error) {
|
||
readEntry := er.read
|
||
|
||
opfPath, err := epubOPFPath(readEntry)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
opfRaw, err := readEntry(opfPath)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
opf, err := parseOPF(opfRaw)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
opfDir := filepath.Dir(opfPath)
|
||
|
||
cover := epubCoverEntry(er.entries, opfDir, opf.coverHref)
|
||
|
||
// href(相对 OPF 目录)→ 标题
|
||
titles := map[string]string{}
|
||
for _, href := range []string{opf.ncxHref, opf.navHref} {
|
||
if href == "" {
|
||
continue
|
||
}
|
||
raw, err := readEntry(filepath.Join(opfDir, href))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for k, v := range parseEPUBTitles(raw) {
|
||
full := filepath.Clean(filepath.Join(opfDir, k))
|
||
if _, ok := titles[full]; !ok {
|
||
titles[full] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
out := make([]epubChapter, 0, len(opf.spine))
|
||
for _, href := range opf.spine {
|
||
full := filepath.Clean(filepath.Join(opfDir, href))
|
||
raw, err := readEntry(filepath.Join(opfDir, href))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
title := titles[full]
|
||
if title == "" {
|
||
// NCX/NAV 里没有这页:正文首行 → 封面页 → <title> → 序号
|
||
body := epubHTMLToText(string(raw), filepath.Dir(full))
|
||
title = firstHeading(body)
|
||
if title == "" && coverLikeEntry(full) && strings.Contains(body, imgMarkerPrefix) {
|
||
title = "封面"
|
||
}
|
||
if title == "" {
|
||
title = meaningfulTitle(htmlHeadTitle(string(raw)))
|
||
}
|
||
if title == "" {
|
||
title = fmt.Sprintf("第 %d 章", len(out)+1)
|
||
}
|
||
}
|
||
out = append(out, epubChapter{Title: title, Href: full})
|
||
}
|
||
return out, cover, nil
|
||
}
|
||
|
||
// epubCoverEntry 定位封面图在 zip 里的条目路径:
|
||
// 优先用 OPF 声明(EPUB3 manifest 的 properties="cover-image" 或 EPUB2 的
|
||
// <meta name="cover">);声明缺失或指向不存在的条目时,退回文件名含 cover 的图片。
|
||
func epubCoverEntry(entries map[string]*zip.File, opfDir, declaredHref string) string {
|
||
if declaredHref != "" {
|
||
full := filepath.Clean(filepath.Join(opfDir, declaredHref))
|
||
if f, ok := entries[full]; ok && isImageEntry(f.Name) {
|
||
return full
|
||
}
|
||
}
|
||
// 兜底:名字像封面(cover.jpg / 封面.png / images/cover.jpeg)的图片
|
||
var fallback string
|
||
for name := range entries {
|
||
base := strings.ToLower(filepath.Base(name))
|
||
if !strings.Contains(base, "cover") && !strings.Contains(base, "封面") {
|
||
continue
|
||
}
|
||
if !isImageEntry(name) {
|
||
continue
|
||
}
|
||
// 多个候选时取路径最短的,通常就是主封面
|
||
if fallback == "" || len(name) < len(fallback) {
|
||
fallback = name
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// isImageEntry 判断 zip 条目是不是图片。
|
||
func isImageEntry(name string) bool {
|
||
switch strings.ToLower(filepath.Ext(name)) {
|
||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif", ".svg":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// coverLikeEntry 文件名像封面页(cover1.html / cover.xhtml / 封面.jpg 等)。
|
||
func coverLikeEntry(entry string) bool {
|
||
name := strings.ToLower(filepath.Base(entry))
|
||
return strings.Contains(name, "cover") || strings.Contains(name, "封面")
|
||
}
|
||
|
||
// meaningfulTitle 过滤掉封面/版权页那类无信息量的 <title>。
|
||
func meaningfulTitle(title string) string {
|
||
switch strings.ToLower(strings.TrimSpace(title)) {
|
||
case "", "cover", "table of contents", "contents", "unknown", "untitled", "title":
|
||
return ""
|
||
}
|
||
return title
|
||
}
|
||
|
||
// epubOPFPath 从 META-INF/container.xml 找 OPF 路径。
|
||
func epubOPFPath(readEntry func(string) ([]byte, error)) (string, error) {
|
||
raw, err := readEntry("META-INF/container.xml")
|
||
if err != nil {
|
||
return "", fmt.Errorf("EPUB 缺少 META-INF/container.xml")
|
||
}
|
||
var c struct {
|
||
Rootfiles []struct {
|
||
FullPath string `xml:"full-path,attr"`
|
||
} `xml:"rootfiles>rootfile"`
|
||
}
|
||
if err := xml.Unmarshal(raw, &c); err != nil {
|
||
return "", fmt.Errorf("解析 container.xml 失败: %w", err)
|
||
}
|
||
for _, rf := range c.Rootfiles {
|
||
if p := strings.TrimSpace(rf.FullPath); p != "" {
|
||
return filepath.Clean(p), nil
|
||
}
|
||
}
|
||
return "", fmt.Errorf("container.xml 里没有 rootfile")
|
||
}
|
||
|
||
type epubPackage struct {
|
||
spine []string
|
||
ncxHref string
|
||
navHref string
|
||
coverHref string // 封面图 href(相对 OPF 目录),取不到为空
|
||
}
|
||
|
||
// parseOPF 解析 OPF:manifest 建 id→href 索引,spine 定顺序,顺带找出 NCX/NAV 目录
|
||
// 与封面图。
|
||
func parseOPF(raw []byte) (*epubPackage, error) {
|
||
var pkg struct {
|
||
Metadata struct {
|
||
Metas []struct {
|
||
Name string `xml:"name,attr"`
|
||
Content string `xml:"content,attr"`
|
||
} `xml:"meta"`
|
||
} `xml:"metadata"`
|
||
Manifest struct {
|
||
Items []struct {
|
||
ID string `xml:"id,attr"`
|
||
Href string `xml:"href,attr"`
|
||
MediaType string `xml:"media-type,attr"`
|
||
Properties string `xml:"properties,attr"`
|
||
} `xml:"item"`
|
||
} `xml:"manifest"`
|
||
Spine struct {
|
||
Toc string `xml:"toc,attr"`
|
||
ItemRefs []struct {
|
||
IDRef string `xml:"idref,attr"`
|
||
} `xml:"itemref"`
|
||
} `xml:"spine"`
|
||
}
|
||
if err := xml.Unmarshal(raw, &pkg); err != nil {
|
||
return nil, fmt.Errorf("解析 OPF 失败: %w", err)
|
||
}
|
||
hrefByID := map[string]string{}
|
||
out := &epubPackage{}
|
||
for _, it := range pkg.Manifest.Items {
|
||
if it.ID != "" {
|
||
hrefByID[it.ID] = it.Href
|
||
}
|
||
switch {
|
||
case strings.Contains(it.Properties, "nav"):
|
||
out.navHref = it.Href
|
||
case strings.EqualFold(it.MediaType, "application/x-dtbncx+xml"):
|
||
if out.ncxHref == "" {
|
||
out.ncxHref = it.Href
|
||
}
|
||
case strings.Contains(it.Properties, "cover-image"):
|
||
// EPUB3 封面
|
||
if out.coverHref == "" {
|
||
out.coverHref = it.Href
|
||
}
|
||
}
|
||
}
|
||
// EPUB2 封面:<meta name="cover" content="封面 item 的 id"/>
|
||
if out.coverHref == "" {
|
||
for _, m := range pkg.Metadata.Metas {
|
||
if !strings.EqualFold(strings.TrimSpace(m.Name), "cover") {
|
||
continue
|
||
}
|
||
if href := hrefByID[strings.TrimSpace(m.Content)]; href != "" {
|
||
out.coverHref = href
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if out.ncxHref == "" && pkg.Spine.Toc != "" {
|
||
out.ncxHref = hrefByID[pkg.Spine.Toc]
|
||
}
|
||
for _, ref := range pkg.Spine.ItemRefs {
|
||
href := hrefByID[ref.IDRef]
|
||
if href == "" {
|
||
continue
|
||
}
|
||
if !strings.HasPrefix(href, "#") {
|
||
out.spine = append(out.spine, href)
|
||
}
|
||
}
|
||
if len(out.spine) == 0 {
|
||
return nil, fmt.Errorf("EPUB spine 为空")
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// parseEPUBTitles 从 NCX(navPoint 树)或 EPUB3 NAV(nav 里的 a)取 相对href → 标题。
|
||
//
|
||
// 用 token 逐个走而不是结构体映射:网上不少 EPUB(如 Epubor 导出的)navPoint
|
||
// 漏了闭合标签,标题会整棵挂在上一个 navPoint 里面,按固定层级解会丢掉一大半;
|
||
// 这里用栈把任意层级的 navPoint 都收下来。
|
||
func parseEPUBTitles(raw []byte) map[string]string {
|
||
out := map[string]string{}
|
||
dec := xml.NewDecoder(bytes.NewReader(raw))
|
||
var (
|
||
stack []navItem
|
||
labelDep int // >0 表示在 navLabel 内
|
||
navDep int // >0 表示在 <nav> 内
|
||
inAnchor bool // 正在读 <a>…</a>
|
||
href string
|
||
text strings.Builder
|
||
)
|
||
pop := func() {
|
||
if n := len(stack); n > 0 {
|
||
it := stack[n-1]
|
||
stack = stack[:n-1]
|
||
putTitle(out, it.src, it.text)
|
||
}
|
||
}
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err != nil {
|
||
break
|
||
}
|
||
switch t := tok.(type) {
|
||
case xml.StartElement:
|
||
switch t.Name.Local {
|
||
case "navPoint":
|
||
stack = append(stack, navItem{})
|
||
case "navLabel":
|
||
labelDep++
|
||
case "text":
|
||
if labelDep > 0 && len(stack) > 0 && stack[len(stack)-1].text == "" {
|
||
var s string
|
||
if dec.DecodeElement(&s, &t) == nil {
|
||
stack[len(stack)-1].text = strings.TrimSpace(s)
|
||
}
|
||
continue
|
||
}
|
||
case "content":
|
||
if len(stack) > 0 && stack[len(stack)-1].src == "" {
|
||
for _, a := range t.Attr {
|
||
if a.Name.Local == "src" {
|
||
stack[len(stack)-1].src = a.Value
|
||
}
|
||
}
|
||
}
|
||
case "nav":
|
||
navDep++
|
||
case "a":
|
||
if navDep > 0 {
|
||
inAnchor, href = true, ""
|
||
text.Reset()
|
||
for _, a := range t.Attr {
|
||
if a.Name.Local == "href" {
|
||
href = a.Value
|
||
}
|
||
}
|
||
}
|
||
}
|
||
case xml.CharData:
|
||
if inAnchor {
|
||
text.Write(t)
|
||
}
|
||
case xml.EndElement:
|
||
switch t.Name.Local {
|
||
case "navLabel":
|
||
if labelDep > 0 {
|
||
labelDep--
|
||
}
|
||
case "navPoint":
|
||
pop()
|
||
case "nav":
|
||
if navDep > 0 {
|
||
navDep--
|
||
}
|
||
case "a":
|
||
if inAnchor {
|
||
inAnchor = false
|
||
putTitle(out, href, strings.TrimSpace(text.String()))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 兜底:文件尾还有没闭合的 navPoint
|
||
for range stack {
|
||
pop()
|
||
}
|
||
return out
|
||
}
|
||
|
||
// navItem NCX 里的一个 navPoint。
|
||
type navItem struct{ src, text string }
|
||
|
||
// putTitle 记录 href → 标题(去掉锚点、去重,先到先得)。
|
||
func putTitle(out map[string]string, href, title string) {
|
||
href = strings.TrimSpace(href)
|
||
if i := strings.IndexByte(href, '#'); i >= 0 {
|
||
href = href[:i]
|
||
}
|
||
if href == "" || title == "" {
|
||
return
|
||
}
|
||
if _, ok := out[href]; !ok {
|
||
out[href] = title
|
||
}
|
||
}
|
||
|
||
// readEPUBEntry 读 zip 内某个 XHTML 并转纯文本(图片转成 [img] 标记行)。
|
||
func readEPUBEntry(epubPath, href string) (string, error) {
|
||
zr, err := zip.OpenReader(epubPath) // #nosec G304 -- 服务端生成的路径
|
||
if err != nil {
|
||
return "", fmt.Errorf("本地书籍文件已丢失: %w", err)
|
||
}
|
||
defer zr.Close()
|
||
target := filepath.Clean(href)
|
||
for _, f := range zr.File {
|
||
if filepath.Clean(f.Name) != target {
|
||
continue
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer rc.Close()
|
||
raw, err := io.ReadAll(io.LimitReader(rc, LocalBookMaxBytes))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return epubHTMLToText(string(raw), filepath.Dir(target)), nil
|
||
}
|
||
return "", fmt.Errorf("EPUB 条目不存在,请重新导入该书")
|
||
}
|
||
|
||
// ReadLocalAsset 读取本地书籍里的资源(目前用于 EPUB 图片)。
|
||
// 返回内容与 MIME 类型。
|
||
func (s *ReaderService) ReadLocalAsset(ctx context.Context, bookID, entry string) ([]byte, string, error) {
|
||
book, err := s.repo.GetBook(ctx, bookID)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if !strings.HasSuffix(book.LocalPath, ".epub") {
|
||
return nil, "", fmt.Errorf("该书籍没有内嵌资源")
|
||
}
|
||
path, err := s.localFilePath(book)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
zr, err := zip.OpenReader(path) // #nosec G304 -- 服务端生成的路径
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("本地书籍文件已丢失: %w", err)
|
||
}
|
||
defer zr.Close()
|
||
target := filepath.Clean(entry)
|
||
for _, f := range zr.File {
|
||
if filepath.Clean(f.Name) != target {
|
||
continue
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
defer rc.Close()
|
||
data, err := io.ReadAll(io.LimitReader(rc, LocalBookMaxBytes))
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return data, imageContentType(entry), nil
|
||
}
|
||
return nil, "", fmt.Errorf("EPUB 内没有该资源")
|
||
}
|
||
|
||
// imageContentType 按扩展名给图片类型(不依赖系统 mime 注册表)。
|
||
func imageContentType(name string) string {
|
||
switch strings.ToLower(filepath.Ext(name)) {
|
||
case ".jpg", ".jpeg":
|
||
return "image/jpeg"
|
||
case ".png":
|
||
return "image/png"
|
||
case ".gif":
|
||
return "image/gif"
|
||
case ".webp":
|
||
return "image/webp"
|
||
case ".svg":
|
||
return "image/svg+xml"
|
||
case ".bmp":
|
||
return "image/bmp"
|
||
case ".avif":
|
||
return "image/avif"
|
||
}
|
||
return "application/octet-stream"
|
||
}
|
||
|
||
// imgTagPattern 匹配 <img ...>,取 src。
|
||
var imgTagPattern = regexp.MustCompile(`(?is)<img\b[^>]*?src\s*=\s*("([^"]*)"|'([^']*)')[^>]*>`)
|
||
|
||
// imgMarkerPrefix 图片在正文里的占位行前缀,前端据此渲染 <img>。
|
||
const imgMarkerPrefix = "[img]"
|
||
|
||
// epubHTMLToText EPUB 正文转纯文本:先把 <img> 换成 [img]条目路径 标记行,
|
||
// 再按普通 HTML 去标签。baseDir 是当前 XHTML 所在目录,用来解析相对路径。
|
||
func epubHTMLToText(src, baseDir string) string {
|
||
src = imgTagPattern.ReplaceAllStringFunc(src, func(tag string) string {
|
||
m := imgTagPattern.FindStringSubmatch(tag)
|
||
if m == nil {
|
||
return ""
|
||
}
|
||
ref := strings.TrimSpace(m[2] + m[3])
|
||
if ref == "" {
|
||
return ""
|
||
}
|
||
ref = strings.ReplaceAll(html.UnescapeString(ref), "\\", "/")
|
||
// data: / 外链图片原样保留;相对路径按当前文件目录解析成 zip 条目
|
||
if !strings.Contains(ref, "://") && !strings.HasPrefix(ref, "data:") {
|
||
if i := strings.IndexByte(ref, '#'); i >= 0 {
|
||
ref = ref[:i]
|
||
}
|
||
ref = filepath.Clean(filepath.Join(baseDir, filepath.FromSlash(ref)))
|
||
}
|
||
return "\n" + imgMarkerPrefix + ref + "\n"
|
||
})
|
||
return htmlToText(src)
|
||
}
|
||
|
||
// htmlHeadTitle 取 <head><title> 文本(EPUB 里常与正文标题一致)。
|
||
func htmlHeadTitle(src string) string {
|
||
m := headTitlePattern.FindStringSubmatch(src)
|
||
if m == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(html.UnescapeString(m[1]))
|
||
}
|
||
|
||
var headTitlePattern = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title\s*>`)
|
||
|
||
// rewriteLocalImages 把正文里的 [img]条目 标记换成签名资源地址。
|
||
func (s *ReaderService) rewriteLocalImages(bookID, text string) string {
|
||
if !strings.Contains(text, imgMarkerPrefix) {
|
||
return text
|
||
}
|
||
lines := strings.Split(text, "\n")
|
||
for i, line := range lines {
|
||
if !strings.HasPrefix(line, imgMarkerPrefix) {
|
||
continue
|
||
}
|
||
entry := strings.TrimSpace(strings.TrimPrefix(line, imgMarkerPrefix))
|
||
if entry == "" || strings.HasPrefix(entry, "/api/") || strings.Contains(entry, "://") {
|
||
continue
|
||
}
|
||
lines[i] = imgMarkerPrefix + s.LocalAssetURL(bookID, entry)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
// signLocalAsset 本地书籍资源的 HMAC 签名(与媒体代理同一套:<img> 带不了 JWT)。
|
||
func (s *ReaderService) signLocalAsset(bookID, entry string) string {
|
||
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
|
||
mac.Write([]byte(bookID + "|local|" + entry))
|
||
return hex.EncodeToString(mac.Sum(nil))[:32]
|
||
}
|
||
|
||
// LocalAssetURL 本地书籍内资源转签名代理地址。
|
||
func (s *ReaderService) LocalAssetURL(bookID, entry string) string {
|
||
return "/api/reader/local/asset?b=" + url.QueryEscape(bookID) +
|
||
"&p=" + base64.RawURLEncoding.EncodeToString([]byte(entry)) +
|
||
"&s=" + s.signLocalAsset(bookID, entry)
|
||
}
|
||
|
||
// VerifyLocalAssetURL 校验签名并还原条目路径。
|
||
func (s *ReaderService) VerifyLocalAssetURL(bookID, encoded, sig string) (string, error) {
|
||
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||
if err != nil {
|
||
return "", fmt.Errorf("资源路径解码失败")
|
||
}
|
||
expect := s.signLocalAsset(bookID, string(raw))
|
||
if !hmac.Equal([]byte(expect), []byte(sig)) {
|
||
return "", fmt.Errorf("资源签名校验失败")
|
||
}
|
||
return string(raw), nil
|
||
}
|
||
|
||
// signLocalAudio 本地有声书音频文件的 HMAC 签名(与本地资源同一套:<audio> 带不了 JWT)。
|
||
func (s *ReaderService) signLocalAudio(bookID, path string) string {
|
||
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
|
||
mac.Write([]byte(bookID + "|audio|" + path))
|
||
return hex.EncodeToString(mac.Sum(nil))[:32]
|
||
}
|
||
|
||
// LocalAudioURL 本地音频文件转签名流地址(服务端按 Range 下发,可拖动进度)。
|
||
func (s *ReaderService) LocalAudioURL(bookID, path string) string {
|
||
return "/api/reader/local/audio?b=" + url.QueryEscape(bookID) +
|
||
"&p=" + base64.RawURLEncoding.EncodeToString([]byte(path)) +
|
||
"&s=" + s.signLocalAudio(bookID, path)
|
||
}
|
||
|
||
// VerifyLocalAudioURL 校验签名并还原音频文件路径。
|
||
func (s *ReaderService) VerifyLocalAudioURL(bookID, encoded, sig string) (string, error) {
|
||
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||
if err != nil {
|
||
return "", fmt.Errorf("音频路径解码失败")
|
||
}
|
||
expect := s.signLocalAudio(bookID, string(raw))
|
||
if !hmac.Equal([]byte(expect), []byte(sig)) {
|
||
return "", fmt.Errorf("音频签名校验失败")
|
||
}
|
||
return string(raw), nil
|
||
}
|
||
|
||
// firstHeading 取正文首个非空行当标题(跳过图片占位行)。
|
||
func firstHeading(text string) string {
|
||
for _, line := range strings.Split(text, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" || strings.HasPrefix(line, imgMarkerPrefix) {
|
||
continue
|
||
}
|
||
if r := []rune(line); len(r) > 40 {
|
||
return string(r[:40])
|
||
}
|
||
return line
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// htmlToText 把 XHTML 转成纯文本:丢 script/style、块级标签转换行、解码实体。
|
||
func htmlToText(src string) string {
|
||
src = regexp.MustCompile(`(?is)<script\b.*?</script\s*>`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`(?is)<style\b.*?</style\s*>`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`(?is)<head\b.*?</head\s*>`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`(?i)<\s*br\s*/?\s*>`).ReplaceAllString(src, "\n")
|
||
src = regexp.MustCompile(`(?i)</\s*(p|div|h[1-6]|li|tr|blockquote|section|article)\s*>`).ReplaceAllString(src, "\n")
|
||
src = regexp.MustCompile(`(?s)<[^>]*>`).ReplaceAllString(src, "")
|
||
src = html.UnescapeString(src)
|
||
src = strings.ReplaceAll(src, "\u00a0", " ")
|
||
lines := strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n")
|
||
out := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
if line = strings.TrimSpace(line); line != "" {
|
||
out = append(out, line)
|
||
}
|
||
}
|
||
return strings.Join(out, "\n")
|
||
}
|