novel-downloader 1.3.3__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 -39
- 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 +22 -22
- novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +35 -40
- novel_downloader/core/{savers → exporters}/common/txt.py +20 -23
- novel_downloader/core/{savers → exporters}/epub_utils/__init__.py +8 -3
- novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -2
- novel_downloader/core/{savers → exporters}/epub_utils/image_loader.py +46 -4
- novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -4
- novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +3 -3
- novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -2
- 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 +20 -14
- 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 +11 -5
- novel_downloader/utils/cookies.py +66 -0
- novel_downloader/utils/crypto_utils.py +1 -74
- 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 +2 -1
- 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 +1 -1
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/METADATA +69 -35
- novel_downloader-1.4.0.dist-info/RECORD +170 -0
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
- {novel_downloader-1.3.3.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 -210
- novel_downloader/core/downloaders/common/common_sync.py +0 -202
- 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 -219
- 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/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.3.dist-info/RECORD +0 -166
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/top_level.txt +0 -0
@@ -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()
|
@@ -0,0 +1,129 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.exporters.linovelib.txt
|
4
|
+
---------------------------------------------
|
5
|
+
|
6
|
+
Contains the logic for exporting novel content as a single `.txt` file.
|
7
|
+
|
8
|
+
This module defines `linovelib_export_as_txt` function, which assembles and formats
|
9
|
+
a novel based on metadata and chapter files found in the raw data directory.
|
10
|
+
It is intended to be used by `LinovelibExporter` as part of the save/export process.
|
11
|
+
"""
|
12
|
+
|
13
|
+
from __future__ import annotations
|
14
|
+
|
15
|
+
import json
|
16
|
+
from typing import TYPE_CHECKING
|
17
|
+
|
18
|
+
from novel_downloader.utils.file_utils import save_as_txt
|
19
|
+
from novel_downloader.utils.text_utils import (
|
20
|
+
format_chapter,
|
21
|
+
)
|
22
|
+
|
23
|
+
if TYPE_CHECKING:
|
24
|
+
from .main_exporter import LinovelibExporter
|
25
|
+
|
26
|
+
|
27
|
+
def linovelib_export_as_txt(
|
28
|
+
exporter: LinovelibExporter,
|
29
|
+
book_id: str,
|
30
|
+
) -> None:
|
31
|
+
"""
|
32
|
+
将 save_path 文件夹中该小说的所有章节 json 文件合并保存为一个完整的 txt 文件,
|
33
|
+
并保存到 out_path 下
|
34
|
+
|
35
|
+
处理流程:
|
36
|
+
1. 从 book_info.json 中加载书籍信息 (包含书名、作者、简介及卷章节列表)
|
37
|
+
2. 遍历各卷, 每个卷先追加卷标题, 然后依次追加该卷下各章节的标题和内容
|
38
|
+
3. 将书籍元信息 (书名、作者、原文截至、内容简介) 与所有章节内容拼接
|
39
|
+
4. 将最终结果保存到 out_path 下 (例如:`{book_name}.txt`)
|
40
|
+
|
41
|
+
:param book_id: Identifier of the novel (used as subdirectory name).
|
42
|
+
"""
|
43
|
+
TAG = "[exporter]"
|
44
|
+
# --- Paths & options ---
|
45
|
+
raw_base = exporter._raw_data_dir / book_id
|
46
|
+
out_dir = exporter.output_dir
|
47
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
48
|
+
|
49
|
+
# --- Load book_info.json ---
|
50
|
+
info_path = raw_base / "book_info.json"
|
51
|
+
try:
|
52
|
+
info_text = info_path.read_text(encoding="utf-8")
|
53
|
+
book_info = json.loads(info_text)
|
54
|
+
except Exception as e:
|
55
|
+
exporter.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
|
56
|
+
return
|
57
|
+
|
58
|
+
# --- Compile chapters ---
|
59
|
+
parts: list[str] = []
|
60
|
+
volumes = book_info.get("volumes", [])
|
61
|
+
|
62
|
+
for vol in volumes:
|
63
|
+
vol_name = vol.get("volume_name", "").strip()
|
64
|
+
vol_intro = vol.get("volume_intro", "").strip()
|
65
|
+
if vol_name:
|
66
|
+
volume_header = f"\n\n{'=' * 6} {vol_name} {'=' * 6}\n\n"
|
67
|
+
parts.append(volume_header)
|
68
|
+
exporter.logger.info("%s Processing volume: %s", TAG, vol_name)
|
69
|
+
if vol_intro:
|
70
|
+
parts.append(f"{vol_intro}\n\n")
|
71
|
+
for chap in vol.get("chapters", []):
|
72
|
+
chap_id = chap.get("chapterId")
|
73
|
+
chap_title = chap.get("title", "")
|
74
|
+
if not chap_id:
|
75
|
+
exporter.logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
|
76
|
+
continue
|
77
|
+
|
78
|
+
chapter_data = exporter._get_chapter(book_id, chap_id)
|
79
|
+
if not chapter_data:
|
80
|
+
exporter.logger.info(
|
81
|
+
"%s Missing chapter file in: %s (%s), skipping.",
|
82
|
+
TAG,
|
83
|
+
chap_title,
|
84
|
+
chap_id,
|
85
|
+
)
|
86
|
+
continue
|
87
|
+
|
88
|
+
# Extract structured fields
|
89
|
+
title = chapter_data.get("title", chap_title).strip()
|
90
|
+
content = chapter_data.get("content", "").strip()
|
91
|
+
|
92
|
+
parts.append(format_chapter(title, content, ""))
|
93
|
+
|
94
|
+
# --- Build header ---
|
95
|
+
name = book_info.get("book_name")
|
96
|
+
author = book_info.get("author")
|
97
|
+
words = book_info.get("word_count")
|
98
|
+
updated = book_info.get("update_time")
|
99
|
+
summary = book_info.get("summary")
|
100
|
+
|
101
|
+
fields = [
|
102
|
+
("书名", name),
|
103
|
+
("作者", author),
|
104
|
+
("总字数", words),
|
105
|
+
("更新日期", updated),
|
106
|
+
]
|
107
|
+
header_lines = [f"{label}: {value}" for label, value in fields if value]
|
108
|
+
|
109
|
+
if summary:
|
110
|
+
header_lines.append("内容简介:")
|
111
|
+
header_lines.append(summary)
|
112
|
+
|
113
|
+
header_lines += ["", "-" * 10, ""]
|
114
|
+
|
115
|
+
header = "\n".join(header_lines)
|
116
|
+
|
117
|
+
final_text = header + "\n\n" + "\n\n".join(parts).strip()
|
118
|
+
|
119
|
+
# --- Determine output file path ---
|
120
|
+
out_name = exporter.get_filename(title=name, author=author, ext="txt")
|
121
|
+
out_path = out_dir / out_name
|
122
|
+
|
123
|
+
# --- Save final text ---
|
124
|
+
try:
|
125
|
+
save_as_txt(content=final_text, filepath=out_path)
|
126
|
+
exporter.logger.info("%s Novel saved to: %s", TAG, out_path)
|
127
|
+
except Exception as e:
|
128
|
+
exporter.logger.error("%s Failed to save file: %s", TAG, e)
|
129
|
+
return
|