novel-downloader 1.3.2__py3-none-any.whl → 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/clean.py +97 -78
- novel_downloader/cli/config.py +177 -0
- novel_downloader/cli/download.py +132 -87
- novel_downloader/cli/export.py +77 -0
- novel_downloader/cli/main.py +21 -28
- novel_downloader/config/__init__.py +1 -25
- novel_downloader/config/adapter.py +32 -31
- novel_downloader/config/loader.py +3 -3
- novel_downloader/config/site_rules.py +1 -2
- novel_downloader/core/__init__.py +3 -6
- novel_downloader/core/downloaders/__init__.py +10 -13
- novel_downloader/core/downloaders/base.py +233 -0
- novel_downloader/core/downloaders/biquge.py +27 -0
- novel_downloader/core/downloaders/common.py +414 -0
- novel_downloader/core/downloaders/esjzone.py +27 -0
- novel_downloader/core/downloaders/linovelib.py +27 -0
- novel_downloader/core/downloaders/qianbi.py +27 -0
- novel_downloader/core/downloaders/qidian.py +352 -0
- novel_downloader/core/downloaders/sfacg.py +27 -0
- novel_downloader/core/downloaders/yamibo.py +27 -0
- novel_downloader/core/exporters/__init__.py +37 -0
- novel_downloader/core/{savers → exporters}/base.py +73 -44
- novel_downloader/core/exporters/biquge.py +25 -0
- novel_downloader/core/exporters/common/__init__.py +12 -0
- novel_downloader/core/{savers → exporters}/common/epub.py +40 -52
- novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +36 -39
- novel_downloader/core/{savers → exporters}/common/txt.py +20 -24
- novel_downloader/core/exporters/epub_utils/__init__.py +40 -0
- novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -1
- novel_downloader/core/exporters/epub_utils/image_loader.py +131 -0
- novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -3
- novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +49 -2
- novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -1
- novel_downloader/core/exporters/esjzone.py +25 -0
- novel_downloader/core/exporters/linovelib/__init__.py +10 -0
- novel_downloader/core/exporters/linovelib/epub.py +449 -0
- novel_downloader/core/exporters/linovelib/main_exporter.py +127 -0
- novel_downloader/core/exporters/linovelib/txt.py +129 -0
- novel_downloader/core/exporters/qianbi.py +25 -0
- novel_downloader/core/{savers → exporters}/qidian.py +8 -8
- novel_downloader/core/exporters/sfacg.py +25 -0
- novel_downloader/core/exporters/yamibo.py +25 -0
- novel_downloader/core/factory/__init__.py +5 -17
- novel_downloader/core/factory/downloader.py +24 -126
- novel_downloader/core/factory/exporter.py +58 -0
- novel_downloader/core/factory/fetcher.py +96 -0
- novel_downloader/core/factory/parser.py +17 -12
- novel_downloader/core/{requesters → fetchers}/__init__.py +22 -15
- novel_downloader/core/{requesters → fetchers}/base/__init__.py +2 -4
- novel_downloader/core/fetchers/base/browser.py +383 -0
- novel_downloader/core/fetchers/base/rate_limiter.py +86 -0
- novel_downloader/core/fetchers/base/session.py +419 -0
- novel_downloader/core/fetchers/biquge/__init__.py +14 -0
- novel_downloader/core/{requesters/biquge/async_session.py → fetchers/biquge/browser.py} +18 -6
- novel_downloader/core/{requesters → fetchers}/biquge/session.py +23 -30
- novel_downloader/core/fetchers/common/__init__.py +14 -0
- novel_downloader/core/fetchers/common/browser.py +79 -0
- novel_downloader/core/{requesters/common/async_session.py → fetchers/common/session.py} +8 -25
- novel_downloader/core/fetchers/esjzone/__init__.py +14 -0
- novel_downloader/core/fetchers/esjzone/browser.py +202 -0
- novel_downloader/core/{requesters/esjzone/async_session.py → fetchers/esjzone/session.py} +62 -42
- novel_downloader/core/fetchers/linovelib/__init__.py +14 -0
- novel_downloader/core/fetchers/linovelib/browser.py +178 -0
- novel_downloader/core/fetchers/linovelib/session.py +178 -0
- novel_downloader/core/fetchers/qianbi/__init__.py +14 -0
- novel_downloader/core/{requesters/qianbi/session.py → fetchers/qianbi/browser.py} +30 -48
- novel_downloader/core/{requesters/qianbi/async_session.py → fetchers/qianbi/session.py} +18 -6
- novel_downloader/core/fetchers/qidian/__init__.py +14 -0
- novel_downloader/core/fetchers/qidian/browser.py +266 -0
- novel_downloader/core/fetchers/qidian/session.py +326 -0
- novel_downloader/core/fetchers/sfacg/__init__.py +14 -0
- novel_downloader/core/fetchers/sfacg/browser.py +189 -0
- novel_downloader/core/{requesters/sfacg/async_session.py → fetchers/sfacg/session.py} +43 -73
- novel_downloader/core/fetchers/yamibo/__init__.py +14 -0
- novel_downloader/core/fetchers/yamibo/browser.py +229 -0
- novel_downloader/core/{requesters/yamibo/async_session.py → fetchers/yamibo/session.py} +62 -44
- novel_downloader/core/interfaces/__init__.py +8 -12
- novel_downloader/core/interfaces/downloader.py +54 -0
- novel_downloader/core/interfaces/{saver.py → exporter.py} +12 -12
- novel_downloader/core/interfaces/fetcher.py +162 -0
- novel_downloader/core/interfaces/parser.py +6 -7
- novel_downloader/core/parsers/__init__.py +5 -6
- novel_downloader/core/parsers/base.py +9 -13
- novel_downloader/core/parsers/biquge/main_parser.py +12 -13
- novel_downloader/core/parsers/common/helper.py +3 -3
- novel_downloader/core/parsers/common/main_parser.py +39 -34
- novel_downloader/core/parsers/esjzone/main_parser.py +24 -17
- novel_downloader/core/parsers/linovelib/__init__.py +10 -0
- novel_downloader/core/parsers/linovelib/main_parser.py +210 -0
- novel_downloader/core/parsers/qianbi/main_parser.py +21 -15
- novel_downloader/core/parsers/qidian/__init__.py +2 -11
- novel_downloader/core/parsers/qidian/book_info_parser.py +113 -0
- novel_downloader/core/parsers/qidian/{browser/chapter_encrypted.py → chapter_encrypted.py} +162 -135
- novel_downloader/core/parsers/qidian/chapter_normal.py +150 -0
- novel_downloader/core/parsers/qidian/{session/chapter_router.py → chapter_router.py} +15 -15
- novel_downloader/core/parsers/qidian/{browser/main_parser.py → main_parser.py} +49 -40
- novel_downloader/core/parsers/qidian/utils/__init__.py +27 -0
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +145 -0
- novel_downloader/core/parsers/qidian/{shared → utils}/helpers.py +41 -68
- novel_downloader/core/parsers/qidian/{session → utils}/node_decryptor.py +64 -50
- novel_downloader/core/parsers/sfacg/main_parser.py +12 -12
- novel_downloader/core/parsers/yamibo/main_parser.py +10 -10
- novel_downloader/locales/en.json +18 -2
- novel_downloader/locales/zh.json +18 -2
- novel_downloader/models/__init__.py +64 -0
- novel_downloader/models/browser.py +21 -0
- novel_downloader/models/chapter.py +25 -0
- novel_downloader/models/config.py +100 -0
- novel_downloader/models/login.py +20 -0
- novel_downloader/models/site_rules.py +99 -0
- novel_downloader/models/tasks.py +33 -0
- novel_downloader/models/types.py +15 -0
- novel_downloader/resources/config/settings.toml +31 -25
- novel_downloader/resources/json/linovelib_font_map.json +3573 -0
- novel_downloader/tui/__init__.py +7 -0
- novel_downloader/tui/app.py +32 -0
- novel_downloader/tui/main.py +17 -0
- novel_downloader/tui/screens/__init__.py +14 -0
- novel_downloader/tui/screens/home.py +191 -0
- novel_downloader/tui/screens/login.py +74 -0
- novel_downloader/tui/styles/home_layout.tcss +79 -0
- novel_downloader/tui/widgets/richlog_handler.py +24 -0
- novel_downloader/utils/__init__.py +6 -0
- novel_downloader/utils/chapter_storage.py +25 -38
- novel_downloader/utils/constants.py +15 -5
- novel_downloader/utils/cookies.py +66 -0
- novel_downloader/utils/crypto_utils.py +1 -74
- novel_downloader/utils/file_utils/io.py +1 -1
- novel_downloader/utils/fontocr/ocr_v1.py +2 -1
- novel_downloader/utils/fontocr/ocr_v2.py +2 -2
- novel_downloader/utils/hash_store.py +10 -18
- novel_downloader/utils/hash_utils.py +3 -2
- novel_downloader/utils/logger.py +2 -3
- novel_downloader/utils/network.py +53 -39
- novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
- novel_downloader/utils/text_utils/font_mapping.py +1 -1
- novel_downloader/utils/text_utils/text_cleaning.py +1 -1
- novel_downloader/utils/time_utils/datetime_utils.py +3 -3
- novel_downloader/utils/time_utils/sleep_utils.py +3 -3
- {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/METADATA +72 -38
- novel_downloader-1.4.0.dist-info/RECORD +170 -0
- {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
- {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/entry_points.txt +1 -0
- novel_downloader/cli/interactive.py +0 -66
- novel_downloader/cli/settings.py +0 -177
- novel_downloader/config/models.py +0 -187
- novel_downloader/core/downloaders/base/__init__.py +0 -14
- novel_downloader/core/downloaders/base/base_async.py +0 -153
- novel_downloader/core/downloaders/base/base_sync.py +0 -208
- novel_downloader/core/downloaders/biquge/__init__.py +0 -14
- novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
- novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
- novel_downloader/core/downloaders/common/__init__.py +0 -14
- novel_downloader/core/downloaders/common/common_async.py +0 -218
- novel_downloader/core/downloaders/common/common_sync.py +0 -210
- novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
- novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
- novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
- novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
- novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
- novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
- novel_downloader/core/downloaders/qidian/__init__.py +0 -10
- novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -227
- novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
- novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
- novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
- novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
- novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
- novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
- novel_downloader/core/factory/requester.py +0 -144
- novel_downloader/core/factory/saver.py +0 -56
- novel_downloader/core/interfaces/async_downloader.py +0 -36
- novel_downloader/core/interfaces/async_requester.py +0 -84
- novel_downloader/core/interfaces/sync_downloader.py +0 -36
- novel_downloader/core/interfaces/sync_requester.py +0 -82
- novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
- novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
- novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
- novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
- novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
- novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
- novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
- novel_downloader/core/requesters/base/async_session.py +0 -410
- novel_downloader/core/requesters/base/browser.py +0 -337
- novel_downloader/core/requesters/base/session.py +0 -378
- novel_downloader/core/requesters/biquge/__init__.py +0 -14
- novel_downloader/core/requesters/common/__init__.py +0 -17
- novel_downloader/core/requesters/common/session.py +0 -113
- novel_downloader/core/requesters/esjzone/__init__.py +0 -13
- novel_downloader/core/requesters/esjzone/session.py +0 -235
- novel_downloader/core/requesters/qianbi/__init__.py +0 -13
- novel_downloader/core/requesters/qidian/__init__.py +0 -21
- novel_downloader/core/requesters/qidian/broswer.py +0 -307
- novel_downloader/core/requesters/qidian/session.py +0 -290
- novel_downloader/core/requesters/sfacg/__init__.py +0 -13
- novel_downloader/core/requesters/sfacg/session.py +0 -242
- novel_downloader/core/requesters/yamibo/__init__.py +0 -13
- novel_downloader/core/requesters/yamibo/session.py +0 -237
- novel_downloader/core/savers/__init__.py +0 -34
- novel_downloader/core/savers/biquge.py +0 -25
- novel_downloader/core/savers/common/__init__.py +0 -12
- novel_downloader/core/savers/epub_utils/__init__.py +0 -26
- novel_downloader/core/savers/esjzone.py +0 -25
- novel_downloader/core/savers/qianbi.py +0 -25
- novel_downloader/core/savers/sfacg.py +0 -25
- novel_downloader/core/savers/yamibo.py +0 -25
- novel_downloader/resources/config/rules.toml +0 -196
- novel_downloader-1.3.2.dist-info/RECORD +0 -165
- {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,7 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
novel_downloader.core.
|
3
|
+
novel_downloader.core.exporters.epub_utils.text_to_html
|
4
|
+
-------------------------------------------------------
|
4
5
|
|
5
6
|
Module for converting raw chapter text to formatted HTML,
|
6
7
|
with automatic word correction and optional image/tag support.
|
@@ -8,13 +9,23 @@ with automatic word correction and optional image/tag support.
|
|
8
9
|
|
9
10
|
import json
|
10
11
|
import logging
|
12
|
+
import re
|
13
|
+
from pathlib import Path
|
11
14
|
from typing import Any
|
12
15
|
|
13
|
-
from novel_downloader.utils.constants import
|
16
|
+
from novel_downloader.utils.constants import (
|
17
|
+
EPUB_IMAGE_WRAPPER,
|
18
|
+
REPLACE_WORD_MAP_PATH,
|
19
|
+
)
|
20
|
+
from novel_downloader.utils.network import download_image
|
14
21
|
from novel_downloader.utils.text_utils import diff_inline_display
|
15
22
|
|
16
23
|
logger = logging.getLogger(__name__)
|
17
24
|
|
25
|
+
_IMG_TAG_PATTERN = re.compile(
|
26
|
+
r'<img\s+[^>]*src=[\'"]([^\'"]+)[\'"][^>]*>', re.IGNORECASE
|
27
|
+
)
|
28
|
+
|
18
29
|
|
19
30
|
# Load and sort replacement map from JSON
|
20
31
|
try:
|
@@ -87,6 +98,42 @@ def chapter_txt_to_html(
|
|
87
98
|
return "\n".join(html_parts)
|
88
99
|
|
89
100
|
|
101
|
+
def inline_remote_images(
|
102
|
+
content: str,
|
103
|
+
image_dir: str | Path,
|
104
|
+
) -> str:
|
105
|
+
"""
|
106
|
+
Download every remote `<img src="...">` in `content` into `image_dir`,
|
107
|
+
and replace the original tag with EPUB_IMAGE_WRAPPER
|
108
|
+
pointing to the local filename.
|
109
|
+
|
110
|
+
:param content: HTML/text of the chapter containing <img> tags.
|
111
|
+
:param image_dir: Directory to save downloaded images into.
|
112
|
+
:return: Modified content with local image references.
|
113
|
+
"""
|
114
|
+
|
115
|
+
def _replace(match: re.Match[str]) -> str:
|
116
|
+
url = match.group(1)
|
117
|
+
try:
|
118
|
+
# download_image returns a Path or None
|
119
|
+
local_path = download_image(
|
120
|
+
url, image_dir, target_name=None, on_exist="skip"
|
121
|
+
)
|
122
|
+
if not local_path:
|
123
|
+
logger.warning(
|
124
|
+
"Failed to download image, leaving original tag: %s", url
|
125
|
+
)
|
126
|
+
return match.group(0)
|
127
|
+
|
128
|
+
# wrap with the EPUB_IMAGE_WRAPPER, inserting just the filename
|
129
|
+
return EPUB_IMAGE_WRAPPER.format(filename=local_path.name)
|
130
|
+
except Exception:
|
131
|
+
logger.exception("Error processing image URL: %s", url)
|
132
|
+
return match.group(0)
|
133
|
+
|
134
|
+
return _IMG_TAG_PATTERN.sub(_replace, content)
|
135
|
+
|
136
|
+
|
90
137
|
def generate_book_intro_html(book_info: dict[str, Any]) -> str:
|
91
138
|
"""
|
92
139
|
Generate HTML string for a book's information and summary.
|
@@ -1,6 +1,7 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
novel_downloader.core.
|
3
|
+
novel_downloader.core.exporters.epub_utils.volume_intro
|
4
|
+
-------------------------------------------------------
|
4
5
|
|
5
6
|
Responsible for generating HTML code for volume introduction pages,
|
6
7
|
including two style variants and a unified entry point.
|
@@ -0,0 +1,25 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.exporters.esjzone
|
4
|
+
---------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from novel_downloader.models import ExporterConfig
|
9
|
+
|
10
|
+
from .common import CommonExporter
|
11
|
+
|
12
|
+
|
13
|
+
class EsjzoneExporter(CommonExporter):
|
14
|
+
def __init__(
|
15
|
+
self,
|
16
|
+
config: ExporterConfig,
|
17
|
+
):
|
18
|
+
super().__init__(
|
19
|
+
config,
|
20
|
+
site="esjzone",
|
21
|
+
chap_folders=["chapters"],
|
22
|
+
)
|
23
|
+
|
24
|
+
|
25
|
+
__all__ = ["EsjzoneExporter"]
|
@@ -0,0 +1,449 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.exporters.linovelib.epub
|
4
|
+
----------------------------------------------
|
5
|
+
|
6
|
+
Contains the logic for exporting novel content as a single `.epub` file.
|
7
|
+
"""
|
8
|
+
|
9
|
+
from __future__ import annotations
|
10
|
+
|
11
|
+
import html
|
12
|
+
import json
|
13
|
+
import re
|
14
|
+
from pathlib import Path
|
15
|
+
from typing import TYPE_CHECKING, Any
|
16
|
+
|
17
|
+
from ebooklib import epub
|
18
|
+
|
19
|
+
from novel_downloader.core.exporters.epub_utils import (
|
20
|
+
add_images_from_dir,
|
21
|
+
add_images_from_list,
|
22
|
+
chapter_txt_to_html,
|
23
|
+
create_css_items,
|
24
|
+
create_volume_intro,
|
25
|
+
init_epub,
|
26
|
+
)
|
27
|
+
from novel_downloader.utils.constants import (
|
28
|
+
DEFAULT_HEADERS,
|
29
|
+
EPUB_IMAGE_FOLDER,
|
30
|
+
EPUB_IMAGE_WRAPPER,
|
31
|
+
EPUB_OPTIONS,
|
32
|
+
EPUB_TEXT_FOLDER,
|
33
|
+
)
|
34
|
+
from novel_downloader.utils.file_utils import sanitize_filename
|
35
|
+
from novel_downloader.utils.network import download_image
|
36
|
+
|
37
|
+
if TYPE_CHECKING:
|
38
|
+
from .main_exporter import LinovelibExporter
|
39
|
+
|
40
|
+
_IMG_TAG_PATTERN = re.compile(
|
41
|
+
r'<img\s+[^>]*src=[\'"]([^\'"]+)[\'"][^>]*>', re.IGNORECASE
|
42
|
+
)
|
43
|
+
_IMG_HEADERS = DEFAULT_HEADERS.copy()
|
44
|
+
_IMG_HEADERS["Referer"] = "https://www.linovelib.com/"
|
45
|
+
|
46
|
+
|
47
|
+
def export_whole_book(
|
48
|
+
exporter: LinovelibExporter,
|
49
|
+
book_id: str,
|
50
|
+
) -> None:
|
51
|
+
"""
|
52
|
+
Export a single novel (identified by `book_id`) to an EPUB file.
|
53
|
+
|
54
|
+
This function will:
|
55
|
+
1. Load `book_info.json` for metadata.
|
56
|
+
2. Generate introductory HTML and optionally include the cover image.
|
57
|
+
3. Initialize the EPUB container.
|
58
|
+
4. Iterate through volumes and chapters, convert each to XHTML.
|
59
|
+
5. Assemble the spine, TOC, CSS and write out the final `.epub`.
|
60
|
+
|
61
|
+
:param book_id: Identifier of the novel (used as subdirectory name).
|
62
|
+
"""
|
63
|
+
TAG = "[exporter]"
|
64
|
+
config = exporter._config
|
65
|
+
# --- Paths & options ---
|
66
|
+
raw_base = exporter._raw_data_dir / book_id
|
67
|
+
img_dir = exporter._cache_dir / book_id / "images"
|
68
|
+
out_dir = exporter.output_dir
|
69
|
+
img_dir.mkdir(parents=True, exist_ok=True)
|
70
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
71
|
+
|
72
|
+
# --- Load book_info.json ---
|
73
|
+
info_path = raw_base / "book_info.json"
|
74
|
+
try:
|
75
|
+
info_text = info_path.read_text(encoding="utf-8")
|
76
|
+
book_info = json.loads(info_text)
|
77
|
+
except Exception as e:
|
78
|
+
exporter.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
|
79
|
+
return
|
80
|
+
|
81
|
+
book_name = book_info.get("book_name", book_id)
|
82
|
+
exporter.logger.info(
|
83
|
+
"%s Starting EPUB generation: %s (ID: %s)", TAG, book_name, book_id
|
84
|
+
)
|
85
|
+
|
86
|
+
# --- Generate intro + cover ---
|
87
|
+
intro_html = _generate_intro_html(book_info)
|
88
|
+
cover_path: Path | None = None
|
89
|
+
cover_url = book_info.get("cover_url", "")
|
90
|
+
if config.include_cover and cover_url:
|
91
|
+
cover_path = download_image(
|
92
|
+
cover_url,
|
93
|
+
raw_base,
|
94
|
+
target_name="cover",
|
95
|
+
headers=_IMG_HEADERS,
|
96
|
+
on_exist="overwrite",
|
97
|
+
)
|
98
|
+
if not cover_path:
|
99
|
+
exporter.logger.warning("Failed to download cover from %s", cover_url)
|
100
|
+
|
101
|
+
# --- Initialize EPUB ---
|
102
|
+
book, spine, toc_list = init_epub(
|
103
|
+
book_info=book_info,
|
104
|
+
book_id=book_id,
|
105
|
+
intro_html=intro_html,
|
106
|
+
book_cover_path=cover_path,
|
107
|
+
include_toc=config.include_toc,
|
108
|
+
)
|
109
|
+
for css in create_css_items(
|
110
|
+
include_main=True,
|
111
|
+
include_volume=True,
|
112
|
+
):
|
113
|
+
book.add_item(css)
|
114
|
+
|
115
|
+
# --- Compile chapters ---
|
116
|
+
volumes = book_info.get("volumes", [])
|
117
|
+
for vol_index, vol in enumerate(volumes, start=1):
|
118
|
+
vol_name = vol.get("volume_name", "").strip() or f"Unknown Volume {vol_index}"
|
119
|
+
vol_name = vol_name.replace(book_name, "").strip()
|
120
|
+
vol_cover_path: Path | None = None
|
121
|
+
vol_cover_url = vol.get("volume_cover", "")
|
122
|
+
if config.include_cover and vol_cover_url:
|
123
|
+
vol_cover_path = download_image(
|
124
|
+
vol_cover_url,
|
125
|
+
img_dir,
|
126
|
+
headers=_IMG_HEADERS,
|
127
|
+
on_exist="skip",
|
128
|
+
)
|
129
|
+
|
130
|
+
exporter.logger.info("Processing volume %d: %s", vol_index, vol_name)
|
131
|
+
|
132
|
+
# Volume intro
|
133
|
+
vol_intro = epub.EpubHtml(
|
134
|
+
title=vol_name,
|
135
|
+
file_name=f"{EPUB_TEXT_FOLDER}/volume_intro_{vol_index}.xhtml",
|
136
|
+
lang="zh",
|
137
|
+
)
|
138
|
+
vol_intro.content = _generate_vol_intro_html(
|
139
|
+
vol_name,
|
140
|
+
vol.get("volume_intro", ""),
|
141
|
+
vol_cover_path,
|
142
|
+
)
|
143
|
+
vol_intro.add_link(
|
144
|
+
href="../Styles/volume-intro.css",
|
145
|
+
rel="stylesheet",
|
146
|
+
type="text/css",
|
147
|
+
)
|
148
|
+
book.add_item(vol_intro)
|
149
|
+
spine.append(vol_intro)
|
150
|
+
|
151
|
+
section = epub.Section(vol_name, vol_intro.file_name)
|
152
|
+
chapter_items: list[epub.EpubHtml] = []
|
153
|
+
|
154
|
+
for chap in vol.get("chapters", []):
|
155
|
+
chap_id = chap.get("chapterId")
|
156
|
+
chap_title = chap.get("title", "")
|
157
|
+
if not chap_id:
|
158
|
+
exporter.logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
|
159
|
+
continue
|
160
|
+
|
161
|
+
chapter_data = exporter._get_chapter(book_id, chap_id)
|
162
|
+
if not chapter_data:
|
163
|
+
exporter.logger.info(
|
164
|
+
"%s Missing chapter file: %s (%s), skipping.",
|
165
|
+
TAG,
|
166
|
+
chap_title,
|
167
|
+
chap_id,
|
168
|
+
)
|
169
|
+
continue
|
170
|
+
|
171
|
+
title = chapter_data.get("title", "") or chap_id
|
172
|
+
content: str = chapter_data.get("content", "")
|
173
|
+
content, _ = _inline_remote_images(content, img_dir)
|
174
|
+
chap_html = chapter_txt_to_html(
|
175
|
+
chapter_title=title,
|
176
|
+
chapter_text=content,
|
177
|
+
author_say="",
|
178
|
+
)
|
179
|
+
|
180
|
+
chap_path = f"{EPUB_TEXT_FOLDER}/{chap_id}.xhtml"
|
181
|
+
item = epub.EpubHtml(title=chap_title, file_name=chap_path, lang="zh")
|
182
|
+
item.content = chap_html
|
183
|
+
item.add_link(
|
184
|
+
href="../Styles/main.css",
|
185
|
+
rel="stylesheet",
|
186
|
+
type="text/css",
|
187
|
+
)
|
188
|
+
book.add_item(item)
|
189
|
+
spine.append(item)
|
190
|
+
chapter_items.append(item)
|
191
|
+
|
192
|
+
toc_list.append((section, chapter_items))
|
193
|
+
|
194
|
+
book = add_images_from_dir(book, img_dir)
|
195
|
+
|
196
|
+
# --- 5. Finalize EPUB ---
|
197
|
+
exporter.logger.info("%s Building TOC and spine...", TAG)
|
198
|
+
book.toc = toc_list
|
199
|
+
book.spine = spine
|
200
|
+
book.add_item(epub.EpubNcx())
|
201
|
+
book.add_item(epub.EpubNav())
|
202
|
+
|
203
|
+
out_name = exporter.get_filename(
|
204
|
+
title=book_name,
|
205
|
+
author=book_info.get("author"),
|
206
|
+
ext="epub",
|
207
|
+
)
|
208
|
+
out_path = out_dir / sanitize_filename(out_name)
|
209
|
+
|
210
|
+
try:
|
211
|
+
epub.write_epub(out_path, book, EPUB_OPTIONS)
|
212
|
+
exporter.logger.info("%s EPUB successfully written to %s", TAG, out_path)
|
213
|
+
except Exception as e:
|
214
|
+
exporter.logger.error("%s Failed to write EPUB to %s: %s", TAG, out_path, e)
|
215
|
+
return
|
216
|
+
|
217
|
+
|
218
|
+
def export_by_volume(
|
219
|
+
exporter: LinovelibExporter,
|
220
|
+
book_id: str,
|
221
|
+
) -> None:
|
222
|
+
"""
|
223
|
+
Export a single novel (identified by `book_id`) to multi EPUB file.
|
224
|
+
|
225
|
+
:param book_id: Identifier of the novel (used as subdirectory name).
|
226
|
+
"""
|
227
|
+
TAG = "[exporter]"
|
228
|
+
config = exporter._config
|
229
|
+
# --- Paths & options ---
|
230
|
+
raw_base = exporter._raw_data_dir / book_id
|
231
|
+
img_dir = exporter._cache_dir / book_id / "images"
|
232
|
+
out_dir = exporter.output_dir
|
233
|
+
img_dir.mkdir(parents=True, exist_ok=True)
|
234
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
235
|
+
|
236
|
+
# --- Load book_info.json ---
|
237
|
+
info_path = raw_base / "book_info.json"
|
238
|
+
try:
|
239
|
+
info_text = info_path.read_text(encoding="utf-8")
|
240
|
+
book_info = json.loads(info_text)
|
241
|
+
except Exception as e:
|
242
|
+
exporter.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
|
243
|
+
return
|
244
|
+
|
245
|
+
book_name = book_info.get("book_name", book_id)
|
246
|
+
exporter.logger.info(
|
247
|
+
"%s Starting EPUB generation: %s (ID: %s)", TAG, book_name, book_id
|
248
|
+
)
|
249
|
+
css_items = create_css_items(
|
250
|
+
include_main=True,
|
251
|
+
include_volume=True,
|
252
|
+
)
|
253
|
+
|
254
|
+
# --- Compile columes ---
|
255
|
+
volumes = book_info.get("volumes", [])
|
256
|
+
for vol_index, vol in enumerate(volumes, start=1):
|
257
|
+
vol_name = vol.get("volume_name", "").strip() or f"Unknown Volume {vol_index}"
|
258
|
+
vol_cover_path: Path | None = None
|
259
|
+
vol_cover_url = vol.get("volume_cover", "")
|
260
|
+
if config.include_cover and vol_cover_url:
|
261
|
+
vol_cover_path = download_image(
|
262
|
+
vol_cover_url,
|
263
|
+
img_dir,
|
264
|
+
headers=_IMG_HEADERS,
|
265
|
+
on_exist="skip",
|
266
|
+
)
|
267
|
+
intro_html = _generate_intro_html(vol)
|
268
|
+
|
269
|
+
book, spine, toc_list = init_epub(
|
270
|
+
book_info=vol,
|
271
|
+
book_id=f"{book_id}_{vol_index}",
|
272
|
+
intro_html=intro_html,
|
273
|
+
book_cover_path=vol_cover_path,
|
274
|
+
include_toc=config.include_toc,
|
275
|
+
)
|
276
|
+
for css in css_items:
|
277
|
+
book.add_item(css)
|
278
|
+
|
279
|
+
for chap in vol.get("chapters", []):
|
280
|
+
chap_id = chap.get("chapterId")
|
281
|
+
chap_title = chap.get("title", "")
|
282
|
+
if not chap_id:
|
283
|
+
exporter.logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
|
284
|
+
continue
|
285
|
+
|
286
|
+
chapter_data = exporter._get_chapter(book_id, chap_id)
|
287
|
+
if not chapter_data:
|
288
|
+
exporter.logger.info(
|
289
|
+
"%s Missing chapter file: %s (%s), skipping.",
|
290
|
+
TAG,
|
291
|
+
chap_title,
|
292
|
+
chap_id,
|
293
|
+
)
|
294
|
+
continue
|
295
|
+
|
296
|
+
title = chapter_data.get("title", "") or chap_id
|
297
|
+
content: str = chapter_data.get("content", "")
|
298
|
+
content, imgs = _inline_remote_images(content, img_dir)
|
299
|
+
chap_html = chapter_txt_to_html(
|
300
|
+
chapter_title=title,
|
301
|
+
chapter_text=content,
|
302
|
+
author_say="",
|
303
|
+
)
|
304
|
+
add_images_from_list(book, imgs)
|
305
|
+
|
306
|
+
chap_path = f"{EPUB_TEXT_FOLDER}/{chap_id}.xhtml"
|
307
|
+
item = epub.EpubHtml(title=chap_title, file_name=chap_path, lang="zh")
|
308
|
+
item.content = chap_html
|
309
|
+
item.add_link(
|
310
|
+
href="../Styles/main.css",
|
311
|
+
rel="stylesheet",
|
312
|
+
type="text/css",
|
313
|
+
)
|
314
|
+
book.add_item(item)
|
315
|
+
spine.append(item)
|
316
|
+
toc_list.append(item)
|
317
|
+
|
318
|
+
book.toc = toc_list
|
319
|
+
book.spine = spine
|
320
|
+
book.add_item(epub.EpubNcx())
|
321
|
+
book.add_item(epub.EpubNav())
|
322
|
+
|
323
|
+
out_name = exporter.get_filename(
|
324
|
+
title=vol_name,
|
325
|
+
author=book_info.get("author"),
|
326
|
+
ext="epub",
|
327
|
+
)
|
328
|
+
out_path = out_dir / sanitize_filename(out_name)
|
329
|
+
|
330
|
+
try:
|
331
|
+
epub.write_epub(out_path, book, EPUB_OPTIONS)
|
332
|
+
exporter.logger.info("%s EPUB successfully written to %s", TAG, out_path)
|
333
|
+
except Exception as e:
|
334
|
+
exporter.logger.error("%s Failed to write EPUB to %s: %s", TAG, out_path, e)
|
335
|
+
return
|
336
|
+
|
337
|
+
|
338
|
+
def _generate_intro_html(
|
339
|
+
info: dict[str, Any],
|
340
|
+
default_author: str = "",
|
341
|
+
) -> str:
|
342
|
+
"""
|
343
|
+
Generate an HTML snippet containing book metadata and summary.
|
344
|
+
|
345
|
+
:param info: A dict that may contain book info
|
346
|
+
:param default_author: Fallback author name.
|
347
|
+
|
348
|
+
:return: An HTML-formatted string.
|
349
|
+
"""
|
350
|
+
title = info.get("book_name") or info.get("volume_name")
|
351
|
+
author = info.get("author") or default_author
|
352
|
+
status = info.get("serial_status")
|
353
|
+
words = info.get("word_count")
|
354
|
+
raw_summary = (info.get("summary") or info.get("volume_intro") or "").strip()
|
355
|
+
|
356
|
+
html_parts = [
|
357
|
+
"<h1>书籍简介</h1>",
|
358
|
+
'<div class="list">',
|
359
|
+
"<ul>",
|
360
|
+
]
|
361
|
+
metadata = [
|
362
|
+
("书名", title),
|
363
|
+
("作者", author),
|
364
|
+
("状态", status),
|
365
|
+
("字数", words),
|
366
|
+
]
|
367
|
+
for label, value in metadata:
|
368
|
+
if value is not None and str(value).strip():
|
369
|
+
safe = html.escape(str(value))
|
370
|
+
if label == "书名":
|
371
|
+
safe = f"《{safe}》"
|
372
|
+
html_parts.append(f"<li>{label}: {safe}</li>")
|
373
|
+
|
374
|
+
html_parts.extend(["</ul>", "</div>"])
|
375
|
+
|
376
|
+
if raw_summary:
|
377
|
+
html_parts.append('<p class="new-page-after"><br/></p>')
|
378
|
+
html_parts.append("<h2>简介</h2>")
|
379
|
+
for para in filter(None, (p.strip() for p in raw_summary.split("\n\n"))):
|
380
|
+
safe_para = html.escape(para).replace("\n", "<br/>")
|
381
|
+
html_parts.append(f"<p>{safe_para}</p>")
|
382
|
+
|
383
|
+
return "\n".join(html_parts)
|
384
|
+
|
385
|
+
|
386
|
+
def _generate_vol_intro_html(
|
387
|
+
title: str,
|
388
|
+
intro: str = "",
|
389
|
+
cover_path: Path | None = None,
|
390
|
+
) -> str:
|
391
|
+
"""
|
392
|
+
Generate the HTML snippet for a volume's introduction section.
|
393
|
+
|
394
|
+
:param title: Title of the volume.
|
395
|
+
:param intro: Optional introduction text for the volume.
|
396
|
+
:param cover_path: Path of the volume cover.
|
397
|
+
:return: HTML string representing the volume's intro section.
|
398
|
+
"""
|
399
|
+
if cover_path is None:
|
400
|
+
return create_volume_intro(title, intro)
|
401
|
+
|
402
|
+
html_parts = [
|
403
|
+
f'<h1 class="volume-title-line1">{title}</h1>',
|
404
|
+
f'<img class="width100" src="../{EPUB_IMAGE_FOLDER}/{cover_path.name}" />',
|
405
|
+
'<p class="new-page-after"><br/></p>',
|
406
|
+
]
|
407
|
+
|
408
|
+
if intro.strip():
|
409
|
+
html_parts.append(f'<p class="intro">{intro}</p>')
|
410
|
+
|
411
|
+
return "\n".join(html_parts)
|
412
|
+
|
413
|
+
|
414
|
+
def _inline_remote_images(
|
415
|
+
content: str,
|
416
|
+
image_dir: str | Path,
|
417
|
+
) -> tuple[str, list[Path]]:
|
418
|
+
"""
|
419
|
+
Download every remote `<img src="...">` in `content` into `image_dir`,
|
420
|
+
and replace the original tag with EPUB_IMAGE_WRAPPER
|
421
|
+
pointing to the local filename.
|
422
|
+
|
423
|
+
:param content: HTML/text of the chapter containing <img> tags.
|
424
|
+
:param image_dir: Directory to save downloaded images into.
|
425
|
+
:return: A tuple (modified_content, list_of_downloaded_image_paths).
|
426
|
+
"""
|
427
|
+
downloaded_images: list[Path] = []
|
428
|
+
|
429
|
+
def _replace(match: re.Match[str]) -> str:
|
430
|
+
url = match.group(1)
|
431
|
+
try:
|
432
|
+
# download_image returns a Path or None
|
433
|
+
local_path = download_image(
|
434
|
+
url,
|
435
|
+
image_dir,
|
436
|
+
target_name=None,
|
437
|
+
headers=_IMG_HEADERS,
|
438
|
+
on_exist="skip",
|
439
|
+
)
|
440
|
+
if not local_path:
|
441
|
+
return match.group(0)
|
442
|
+
|
443
|
+
downloaded_images.append(local_path)
|
444
|
+
return EPUB_IMAGE_WRAPPER.format(filename=local_path.name)
|
445
|
+
except Exception:
|
446
|
+
return match.group(0)
|
447
|
+
|
448
|
+
modified_content = _IMG_TAG_PATTERN.sub(_replace, content)
|
449
|
+
return modified_content, downloaded_images
|
@@ -0,0 +1,127 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.exporters.linovelib.main_exporter
|
4
|
+
-------------------------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from collections.abc import Mapping
|
9
|
+
from typing import Any
|
10
|
+
|
11
|
+
from novel_downloader.core.exporters.base import BaseExporter
|
12
|
+
from novel_downloader.models import ExporterConfig
|
13
|
+
from novel_downloader.utils.chapter_storage import ChapterStorage
|
14
|
+
|
15
|
+
from .txt import linovelib_export_as_txt
|
16
|
+
|
17
|
+
|
18
|
+
class LinovelibExporter(BaseExporter):
|
19
|
+
""""""
|
20
|
+
|
21
|
+
def __init__(
|
22
|
+
self,
|
23
|
+
config: ExporterConfig,
|
24
|
+
):
|
25
|
+
"""
|
26
|
+
Initialize the linovelib exporter.
|
27
|
+
|
28
|
+
:param config: A ExporterConfig object that defines
|
29
|
+
save paths, formats, and options.
|
30
|
+
"""
|
31
|
+
super().__init__(config, "linovelib")
|
32
|
+
self._chapter_storage_cache: dict[str, list[ChapterStorage]] = {}
|
33
|
+
self._chap_folders: list[str] = ["chapters"]
|
34
|
+
|
35
|
+
def export_as_txt(self, book_id: str) -> None:
|
36
|
+
"""
|
37
|
+
Compile and export a novel as a single .txt file.
|
38
|
+
|
39
|
+
:param book_id: The book identifier (used to locate raw data)
|
40
|
+
"""
|
41
|
+
self._init_chapter_storages(book_id)
|
42
|
+
return linovelib_export_as_txt(self, book_id)
|
43
|
+
|
44
|
+
def export_as_epub(self, book_id: str) -> None:
|
45
|
+
"""
|
46
|
+
Persist the assembled book as a EPUB (.epub) file.
|
47
|
+
|
48
|
+
:param book_id: The book identifier.
|
49
|
+
:raises NotImplementedError: If the method is not overridden.
|
50
|
+
"""
|
51
|
+
try:
|
52
|
+
from .epub import (
|
53
|
+
export_by_volume,
|
54
|
+
export_whole_book,
|
55
|
+
)
|
56
|
+
except ImportError as err:
|
57
|
+
raise NotImplementedError(
|
58
|
+
"EPUB export not supported. Please install 'ebooklib'"
|
59
|
+
) from err
|
60
|
+
|
61
|
+
self._init_chapter_storages(book_id)
|
62
|
+
|
63
|
+
exporters = {
|
64
|
+
"volume": export_by_volume,
|
65
|
+
"book": export_whole_book,
|
66
|
+
}
|
67
|
+
try:
|
68
|
+
export_fn = exporters[self._config.split_mode]
|
69
|
+
except KeyError as err:
|
70
|
+
raise ValueError(
|
71
|
+
f"Unsupported split_mode: {self._config.split_mode!r}"
|
72
|
+
) from err
|
73
|
+
return export_fn(self, book_id)
|
74
|
+
|
75
|
+
@property
|
76
|
+
def site(self) -> str:
|
77
|
+
"""
|
78
|
+
Get the site identifier.
|
79
|
+
|
80
|
+
:return: The site string.
|
81
|
+
"""
|
82
|
+
return self._site
|
83
|
+
|
84
|
+
@site.setter
|
85
|
+
def site(self, value: str) -> None:
|
86
|
+
"""
|
87
|
+
Set the site identifier.
|
88
|
+
|
89
|
+
:param value: New site string to set.
|
90
|
+
"""
|
91
|
+
self._site = value
|
92
|
+
|
93
|
+
def _get_chapter(
|
94
|
+
self,
|
95
|
+
book_id: str,
|
96
|
+
chap_id: str,
|
97
|
+
) -> Mapping[str, Any]:
|
98
|
+
for storage in self._chapter_storage_cache[book_id]:
|
99
|
+
data = storage.get(chap_id)
|
100
|
+
if data:
|
101
|
+
return data
|
102
|
+
return {}
|
103
|
+
|
104
|
+
def _init_chapter_storages(self, book_id: str) -> None:
|
105
|
+
if book_id in self._chapter_storage_cache:
|
106
|
+
return
|
107
|
+
raw_base = self._raw_data_dir / book_id
|
108
|
+
self._chapter_storage_cache[book_id] = [
|
109
|
+
ChapterStorage(
|
110
|
+
raw_base=raw_base,
|
111
|
+
namespace=ns,
|
112
|
+
backend_type=self._config.storage_backend,
|
113
|
+
)
|
114
|
+
for ns in self._chap_folders
|
115
|
+
]
|
116
|
+
|
117
|
+
def _on_close(self) -> None:
|
118
|
+
"""
|
119
|
+
Close all ChapterStorage connections in the cache.
|
120
|
+
"""
|
121
|
+
for storages in self._chapter_storage_cache.values():
|
122
|
+
for storage in storages:
|
123
|
+
try:
|
124
|
+
storage.close()
|
125
|
+
except Exception as e:
|
126
|
+
self.logger.warning("Failed to close storage %s: %s", storage, e)
|
127
|
+
self._chapter_storage_cache.clear()
|