一. 前言


亚马逊中国将在2023 年6 月30 日之后停止运营 Kindle 电子书店,正式退出中国市场,自此用户将无法再通过亚马逊中国账户继续购买电子书。

Z-Library 的域名日前被美国邮政检查局查封。

现在的电子设备使用越来越频繁,它的便捷性不言而喻,阅读书籍也可以借助电子设备这种方便的工具。

在使用了各种阅读 App 后,觉得还是有一个自己的图书馆更方便,平时又喜欢搜集网络上的精品电子书,那么管理就成为了一个问题,放在硬盘的一堆电子书文件,想看也不知道看哪本,然后就想到是否可以搭建一个电子书管理平台,方便管理,搜索和下载阅读,就开始查阅网上各大神的教程,

于是就有了如下这篇搭建自己图书馆的记录。

二. 介绍


Calibre-Web 适合用来管理哪些内容:

其实除了电子书外,calibre-web 管理漫画、CG 和画集也是很方便的。

除了已经是 epub 格式的内容,其实一些其他常见漫画格式也能很方便的添加进 calibre-web 里。

比如漫画有很多只是把图片打包成 zip 文件而已,而 calibre-web 有人觉得没法直接认出 zip 格式的文件会不方便管理,其实直接把文件后缀从 zip 改成 cbz,calibre-web 就能直接认出来了,并且能直接在线阅读。这样一来很多 zip 漫画就不用费力去转 epub 格式了。(rar 和 7z 格式也是同理,但如果压缩包内有图片之外的文件或者 png 和 gif 格式的图片 calibre-web 目前认在线阅读认不出来,但可以下载下来)。

Calibre web 又有什么功能呢

calibre-web 功能很强大,主要有以下几点

  • 无缝集成 calibre 单机版图书馆,只要将 calibre 的数据库文件和相应的图书复制进 calibre-web 指定目录,calibre 的所有书籍就可以通过 web 端管理阅读、推送了
  • 在线阅读 pdf、epub 等格式,支持的电子书格式众多。
  • 支持电子书格式在线转换,如 pdf → epub,若出现不能在线阅读,就将格式进行转换。
  • 支持图书推送到自己的 kindle 设备
  • 支持在线注册用户,用户权限管理(如管理员可指定用户是否拥有上传、阅读、下载等权限)

三. 效果预览


UI 看起来还是略显古老些许

四. 相关地址


五. 部署教程


1. Docker CLI:

docker run -d \
  --name calibre-web \
  --restart always \
  --network bridge \
  --cpus='1.0' \
  --memory='1024m' \
  -e PUID=0 \
  -e PGID=0 \
  -e TZ=Asia/Shanghai \
  -p 8083:8083 \
  -v $(pwd)/calibre-web/config:/config \
  -v $(pwd)/calibre-web/NewDouban.py:/calibre-web/cps/metadata_provider \
  -v $(pwd)/book:/books \
  linuxserver/calibre-web:0.6.20

2. Docker Compose:

version: '3'
services:
  calibre-web:
    image: linuxserver/calibre-web:0.6.20  # 拉取固定版本镜像
    container_name: calibre-web            # 容器名称
    restart: always                        # 重启策略
    network_mode: bridge
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 1024M
    environment:
      - PUID=0
      - PGID=0
      - TZ=Asia/Shanghai            # 容器时区
#      - CALIBRE_LOCALHOST=true
#      - DOCKER_MODS=linuxserver/mods:universal-calibre # 可选 仅限 x86-64 添加执行电子书转换的功能
#      - OAUTHLIB_RELAX_TOKEN_SCOPE=1                   # 可选择设置此项以允许 Google OAUTH 工作
    ports:
      - '8083:8083'                 # 映射端口:主机 8083 -> 容器 8083
    volumes:
      - ./calibre-web/config:/config                                    # 映射 cw 配置路径
      - ./calibre-web/NewDouban.py:/calibre-web/cps/metadata_provider   # 映射豆瓣 provider
      - ./book:/books                                                   # 映射数据库路径(本地图书存放的位置)

其中 -v /books 第一次需要一个初始 metadata.db 数据库文件

补充:官方文档写的 GUID \ PGID 都填 1000 ,这边不能按照官方的来,因为你不知道你系统的用户 id 是多少,如果要填写具体数据,就要在 ssh 在执行 id 用户名 来查看具体的 PGID PUID,这里使用 $(id -u) $(id -g) 是一劳永逸的写法,程序运行到此就是执行命令自行获取相应的id。

数据库:metadata.db

为什么需要这个数据库文件?

由于 calibre-web 是基于 Calibre 这款软件的,它并不负责图书库的创建,所以需要有一个数据仓库, metadata.db 就是这个数据仓库。如果你不使用我的这个空库文件,也可以自己本地 创建图书库

如果您没有 Calibre 数据库,您可以使用 此数据库

  • 默认用户:

    • 用户名:admin

    • 密码:password

六. 使用教程


1. 网页管理

浏览器打开 NAS_IP:8083 就可以访问项目界面了,默认登录账号是:admin / admin123

2. 配置图书馆

在 calibre-web 搭建完成后,我们要使用它的所有功能,我们要进行详细的配置。配置为了解决以下问题

2.1 数据库

添加数据库路径为 /books (默认写法,不可更改)

2.2 修改语言

进入右上角人头像 adminlanguage 中将语言切换成中文并保存。

2.3 书籍转换

点击页面右上角 管理权限编辑基本配置扩展程序配置,进行以下修改后点击 保存

- Calibre 电子书转换器路径/usr/bin/ebook-convert

- KEpubify 电子书转换器路径/usr/bin/kepubify

2.4 启用上传

进入 管理权限编辑基本配置功能配置启动上传 打钩,启动注册,使用邮箱或用户名打开(Tips:注册功能需要配置发送邮箱!)

2.5 创建图书库

在电脑中安装 https://calibre-ebook.com/download,然后用电脑挂载群晖,在此位置建立一个空白书库(注意:群晖需要安装 WebDAV Server)。

完成后,就会在该位置生成一个 metadata.db 的文件。

七. 常见问题及注意点


1. 数据库无法读取问题

请注意 docker-compose.yaml 里的 PUGDPGID 是否填写正确

2. 无法上传问题

请注意你是否打开了用户上传的权限。

3. 豆瓣元数据无法获取

豆瓣 Api 很早以前就已经不开发给个人用户使用了,目前的豆瓣 API provider 是自己开发的,使用 python 从豆瓣网站上抓取网页并解析成对应格式数据的方法实现

豆瓣 Api 镜像发布到 Docker 仓库可以启动后供 javascript 调用,但是新版 calibre-web 不再使用 javascript 调用外部服务的方式获取元数据,因此根据 calibre-web 的 metadata_provider 规范开发了 python 版本并开源到 Github 上:

Github地址:fugary/calibre-web-douban-api

使用方式是复制 src/NewDouban.pycalibre-web/cps/metadata_provider/ 目录下,重启项目即可,不过在群晖 Docker 环境下可以在下载后简单通过挂接的方式把文件直接挂接过去。

下载文件地址:NewDouban.py

代理下载地址:NewDouban.py

下载后存到自己的群晖中,我目前存储在 ./Calibre-Web/NewDouban.py

保存下方代码内容为 NewDouban.py 文件

import re
import time

import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urlparse, unquote
from lxml import etree
from functools import lru_cache

from cps.services.Metadata import Metadata, MetaSourceInfo, MetaRecord

DOUBAN_SEARCH_JSON_URL = "https://www.douban.com/j/search" # 最新豆瓣屏蔽此url
DOUBAN_SEARCH_URL = "https://www.douban.com/search"
DOUBAN_SEARCH_NEW_MODE = True
DOUBAN_BOOK_CAT = "1001"
DOUBAN_BOOK_CACHE_SIZE = 500  # 最大缓存数量
DOUBAN_CONCURRENCY_SIZE = 5  # 并发查询数
DOUBAN_BOOK_URL_PATTERN = re.compile(".*/subject/(\\d+)/?")
DEFAULT_HEADERS = {
    'user-agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3573.0 Safari/537.36'
}
PROVIDER_NAME = "New Douban Books"
PROVIDER_ID = "new_douban"


class NewDouban(Metadata):
    __name__ = PROVIDER_NAME
    __id__ = PROVIDER_ID

    def __init__(self):
        self.searcher = DoubanBookSearcher()
        super().__init__()

    def search(self, query: str, generic_cover: str = "", locale: str = "en"):
        if self.active:
            return self.searcher.search_books(query)


class DoubanBookSearcher:

    def __init__(self):
        self.book_loader = DoubanBookLoader()
        self.thread_pool = ThreadPoolExecutor(max_workers=10, thread_name_prefix='douban_async')

    def calc_url(self, href):
        query = urlparse(href).query
        params = {item.split('=')[0]: item.split('=')[1] for item in query.split('&')}
        url = unquote(params['url'])
        if DOUBAN_BOOK_URL_PATTERN.match(url):
            return url

    def load_book_urls(self, query):
        url = DOUBAN_SEARCH_JSON_URL
        params = {"start": 0, "cat": DOUBAN_BOOK_CAT, "q": query}
        res = requests.get(url, params, headers=DEFAULT_HEADERS)
        book_urls = []
        if res.status_code in [200, 201]:
            book_list_content = res.json()
            for item in book_list_content['items']:
                if len(book_urls) < DOUBAN_CONCURRENCY_SIZE: # 获取部分数据,默认5条
                    html = etree.HTML(item)
                    a = html.xpath('//a[@class="nbg"]')
                    if len(a):
                        href = a[0].attrib['href']
                        parsed = self.calc_url(href)
                        if parsed:
                            book_urls.append(parsed)
        return book_urls

    def load_book_urls_new(self, query):
        url = DOUBAN_SEARCH_URL
        params = {"cat": DOUBAN_BOOK_CAT, "q": query}
        res = requests.get(url, params, headers=DEFAULT_HEADERS)
        book_urls = []
        if res.status_code in [200, 201]:
            html = etree.HTML(res.content)
            alist = html.xpath('//a[@class="nbg"]')
            for link in alist:
                href = link.attrib['href']
                parsed = self.calc_url(href)
                if parsed:
                    if len(book_urls) < DOUBAN_CONCURRENCY_SIZE:
                        book_urls.append(parsed)
        return book_urls

    def search_books(self, query):
        if DOUBAN_SEARCH_NEW_MODE:
            book_urls = self.load_book_urls_new(query)
        else:
            book_urls = self.load_book_urls(query)
        books = []
        futures = [self.thread_pool.submit(self.book_loader.load_book, book_url) for book_url in book_urls]
        for future in as_completed(futures):
            book = future.result()
            if book is not None:
                books.append(future.result())
        return books


class DoubanBookLoader:

    def __init__(self):
        self.book_parser = DoubanBookHtmlParser()

    @lru_cache(maxsize=DOUBAN_BOOK_CACHE_SIZE)
    def load_book(self, url):
        book = None
        start_time = time.time()
        res = requests.get(url, headers=DEFAULT_HEADERS)
        if res.status_code in [200, 201]:
            print("下载书籍:{}成功,耗时{:.0f}ms".format(url, (time.time() - start_time) * 1000))
            book_detail_content = res.content
            book = self.book_parser.parse_book(url, book_detail_content.decode("utf8"))
        return book


class DoubanBookHtmlParser:
    def __init__(self):
        self.id_pattern = DOUBAN_BOOK_URL_PATTERN
        self.date_pattern = re.compile("(\\d{4})-(\\d+)")
        self.tag_pattern = re.compile("criteria = '(.+)'")

    def parse_book(self, url, book_content):
        book = MetaRecord(
            id="",
            title="",
            authors=[],
            publisher="",
            description="",
            url="",
            source=MetaSourceInfo(
                id=PROVIDER_ID,
                description=PROVIDER_NAME,
                link="https://book.douban.com/"
            )
        )
        html = etree.HTML(book_content)
        title_element = html.xpath("//span[@property='v:itemreviewed']")
        book.title = self.get_text(title_element)
        share_element = html.xpath("//a[@data-url]")
        if len(share_element):
            url = share_element[0].attrib['data-url']
        book.url = url
        id_match = self.id_pattern.match(url)
        if id_match:
            book.id = id_match.group(1)
        img_element = html.xpath("//a[@class='nbg']")
        if len(img_element):
            cover = img_element[0].attrib['href']
            if not cover or cover.endswith('update_image'):
                book.cover = ''
            else:
                book.cover = cover
        rating_element = html.xpath("//strong[@property='v:average']")
        book.rating = self.get_rating(rating_element)
        elements = html.xpath("//span[@class='pl']")
        for element in elements:
            text = self.get_text(element)
            if text.startswith("作者") or text.startswith("译者"):
                book.authors.extend([self.get_text(author_element) for author_element in
                                     filter(self.author_filter, element.findall("..//a"))])
            elif text.startswith("出版社"):
                book.publisher = self.get_tail(element)
            elif text.startswith("副标题"):
                book.title = book.title + ':' + self.get_tail(element)
            elif text.startswith("出版年"):
                book.publishedDate = self.get_publish_date(self.get_tail(element))
            elif text.startswith("丛书"):
                book.series = self.get_text(element.getnext())
        summary_element = html.xpath("//div[@id='link-report']//div[@class='intro']")
        if len(summary_element):
            book.description = etree.tostring(summary_element[-1], encoding="utf8").decode("utf8").strip()
        tag_elements = html.xpath("//a[contains(@class, 'tag')]")
        if len(tag_elements):
            book.tags = [self.get_text(tag_element) for tag_element in tag_elements]
        else:
            book.tags = self.get_tags(book_content)
        return book

    def get_tags(self, book_content):
        tag_match = self.tag_pattern.findall(book_content)
        if len(tag_match):
            return [tag.replace('7:', '') for tag in
                    filter(lambda tag: tag and tag.startswith('7:'), tag_match[0].split('|'))]
        return []

    def get_publish_date(self, date_str):
        if date_str:
            date_match = self.date_pattern.fullmatch(date_str)
            if date_match:
                date_str = "{}-{}-1".format(date_match.group(1), date_match.group(2))
        return date_str

    def get_rating(self, rating_element):
        return float(self.get_text(rating_element, '0')) / 2

    def author_filter(self, a_element):
        a_href = a_element.attrib['href']
        return '/author' in a_href or '/search' in a_href

    def get_text(self, element, default_str=''):
        text = default_str
        if len(element) and element[0].text:
            text = element[0].text.strip()
        elif isinstance(element, etree._Element) and element.text:
            text = element.text.strip()
        return text if text else default_str

    def get_tail(self, element, default_str=''):
        text = default_str
        if isinstance(element, etree._Element) and element.tail:
            text = element.tail.strip()
            if not text:
                text = self.get_text(element.getnext(), default_str)
        return text if text else default_str

总之,搭建过程还是较为麻烦,涉及的方面较多,需要了解相关的基础知识,但是此篇教程我写的较为详细,相信只要耐心都能搭建成功,毕竟我使用这个有一段时间了,有的需要注意的地方我都写出来了,Docker 映像也选择好了, 注意文件夹的权限是一个大坑,搭建不成功很大方面是这个原因。

参考资料