novel-downloader 1.5.0__py3-none-any.whl → 2.0.1__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/__init__.py +1 -3
- novel_downloader/cli/clean.py +21 -88
- novel_downloader/cli/config.py +26 -21
- novel_downloader/cli/download.py +79 -66
- novel_downloader/cli/export.py +17 -21
- novel_downloader/cli/main.py +1 -1
- novel_downloader/cli/search.py +62 -65
- novel_downloader/cli/ui.py +156 -0
- novel_downloader/config/__init__.py +8 -5
- novel_downloader/config/adapter.py +206 -209
- novel_downloader/config/{loader.py → file_io.py} +53 -26
- novel_downloader/core/__init__.py +5 -5
- novel_downloader/core/archived/deqixs/fetcher.py +115 -0
- novel_downloader/core/archived/deqixs/parser.py +132 -0
- novel_downloader/core/archived/deqixs/searcher.py +89 -0
- novel_downloader/core/{searchers/qidian.py → archived/qidian/searcher.py} +12 -20
- novel_downloader/core/archived/wanbengo/searcher.py +98 -0
- novel_downloader/core/archived/xshbook/searcher.py +93 -0
- novel_downloader/core/downloaders/__init__.py +3 -24
- novel_downloader/core/downloaders/base.py +49 -23
- novel_downloader/core/downloaders/common.py +191 -137
- novel_downloader/core/downloaders/qianbi.py +187 -146
- novel_downloader/core/downloaders/qidian.py +187 -141
- novel_downloader/core/downloaders/registry.py +4 -2
- novel_downloader/core/downloaders/signals.py +46 -0
- novel_downloader/core/exporters/__init__.py +3 -20
- novel_downloader/core/exporters/base.py +33 -37
- novel_downloader/core/exporters/common/__init__.py +1 -2
- novel_downloader/core/exporters/common/epub.py +15 -10
- novel_downloader/core/exporters/common/main_exporter.py +19 -12
- novel_downloader/core/exporters/common/txt.py +17 -12
- novel_downloader/core/exporters/epub_util.py +59 -29
- novel_downloader/core/exporters/linovelib/__init__.py +1 -0
- novel_downloader/core/exporters/linovelib/epub.py +23 -25
- novel_downloader/core/exporters/linovelib/main_exporter.py +8 -12
- novel_downloader/core/exporters/linovelib/txt.py +20 -14
- novel_downloader/core/exporters/qidian.py +2 -8
- novel_downloader/core/exporters/registry.py +4 -2
- novel_downloader/core/exporters/txt_util.py +7 -7
- novel_downloader/core/fetchers/__init__.py +54 -48
- novel_downloader/core/fetchers/aaatxt.py +83 -0
- novel_downloader/core/fetchers/{biquge/session.py → b520.py} +6 -11
- novel_downloader/core/fetchers/{base/session.py → base.py} +37 -46
- novel_downloader/core/fetchers/{biquge/browser.py → biquyuedu.py} +12 -17
- novel_downloader/core/fetchers/dxmwx.py +110 -0
- novel_downloader/core/fetchers/eightnovel.py +139 -0
- novel_downloader/core/fetchers/{esjzone/session.py → esjzone.py} +19 -12
- novel_downloader/core/fetchers/guidaye.py +85 -0
- novel_downloader/core/fetchers/hetushu.py +92 -0
- novel_downloader/core/fetchers/{qianbi/browser.py → i25zw.py} +19 -28
- novel_downloader/core/fetchers/ixdzs8.py +113 -0
- novel_downloader/core/fetchers/jpxs123.py +101 -0
- novel_downloader/core/fetchers/lewenn.py +83 -0
- novel_downloader/core/fetchers/{linovelib/session.py → linovelib.py} +12 -13
- novel_downloader/core/fetchers/piaotia.py +105 -0
- novel_downloader/core/fetchers/qbtr.py +101 -0
- novel_downloader/core/fetchers/{qianbi/session.py → qianbi.py} +5 -10
- novel_downloader/core/fetchers/{qidian/session.py → qidian.py} +56 -64
- novel_downloader/core/fetchers/quanben5.py +92 -0
- novel_downloader/core/fetchers/{base/rate_limiter.py → rate_limiter.py} +2 -2
- novel_downloader/core/fetchers/registry.py +5 -16
- novel_downloader/core/fetchers/{sfacg/session.py → sfacg.py} +7 -10
- novel_downloader/core/fetchers/shencou.py +106 -0
- novel_downloader/core/fetchers/shuhaige.py +84 -0
- novel_downloader/core/fetchers/tongrenquan.py +84 -0
- novel_downloader/core/fetchers/ttkan.py +95 -0
- novel_downloader/core/fetchers/wanbengo.py +83 -0
- novel_downloader/core/fetchers/xiaoshuowu.py +106 -0
- novel_downloader/core/fetchers/xiguashuwu.py +177 -0
- novel_downloader/core/fetchers/xs63b.py +171 -0
- novel_downloader/core/fetchers/xshbook.py +85 -0
- novel_downloader/core/fetchers/{yamibo/session.py → yamibo.py} +19 -12
- novel_downloader/core/fetchers/yibige.py +114 -0
- novel_downloader/core/interfaces/__init__.py +1 -9
- novel_downloader/core/interfaces/downloader.py +6 -2
- novel_downloader/core/interfaces/exporter.py +7 -7
- novel_downloader/core/interfaces/fetcher.py +6 -19
- novel_downloader/core/interfaces/parser.py +7 -8
- novel_downloader/core/interfaces/searcher.py +9 -1
- novel_downloader/core/parsers/__init__.py +49 -12
- novel_downloader/core/parsers/aaatxt.py +132 -0
- novel_downloader/core/parsers/b520.py +116 -0
- novel_downloader/core/parsers/base.py +64 -12
- novel_downloader/core/parsers/biquyuedu.py +133 -0
- novel_downloader/core/parsers/dxmwx.py +162 -0
- novel_downloader/core/parsers/eightnovel.py +224 -0
- novel_downloader/core/parsers/esjzone.py +64 -69
- novel_downloader/core/parsers/guidaye.py +128 -0
- novel_downloader/core/parsers/hetushu.py +139 -0
- novel_downloader/core/parsers/i25zw.py +137 -0
- novel_downloader/core/parsers/ixdzs8.py +186 -0
- novel_downloader/core/parsers/jpxs123.py +137 -0
- novel_downloader/core/parsers/lewenn.py +142 -0
- novel_downloader/core/parsers/linovelib.py +48 -64
- novel_downloader/core/parsers/piaotia.py +189 -0
- novel_downloader/core/parsers/qbtr.py +136 -0
- novel_downloader/core/parsers/qianbi.py +48 -50
- novel_downloader/core/parsers/qidian/main_parser.py +756 -48
- novel_downloader/core/parsers/qidian/utils/__init__.py +3 -21
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +1 -1
- novel_downloader/core/parsers/qidian/utils/node_decryptor.py +4 -4
- novel_downloader/core/parsers/quanben5.py +103 -0
- novel_downloader/core/parsers/registry.py +5 -16
- novel_downloader/core/parsers/sfacg.py +38 -45
- novel_downloader/core/parsers/shencou.py +215 -0
- novel_downloader/core/parsers/shuhaige.py +111 -0
- novel_downloader/core/parsers/tongrenquan.py +116 -0
- novel_downloader/core/parsers/ttkan.py +132 -0
- novel_downloader/core/parsers/wanbengo.py +191 -0
- novel_downloader/core/parsers/xiaoshuowu.py +173 -0
- novel_downloader/core/parsers/xiguashuwu.py +429 -0
- novel_downloader/core/parsers/xs63b.py +161 -0
- novel_downloader/core/parsers/xshbook.py +134 -0
- novel_downloader/core/parsers/yamibo.py +87 -131
- novel_downloader/core/parsers/yibige.py +166 -0
- novel_downloader/core/searchers/__init__.py +34 -3
- novel_downloader/core/searchers/aaatxt.py +107 -0
- novel_downloader/core/searchers/{biquge.py → b520.py} +29 -28
- novel_downloader/core/searchers/base.py +112 -36
- novel_downloader/core/searchers/dxmwx.py +105 -0
- novel_downloader/core/searchers/eightnovel.py +84 -0
- novel_downloader/core/searchers/esjzone.py +43 -25
- novel_downloader/core/searchers/hetushu.py +92 -0
- novel_downloader/core/searchers/i25zw.py +93 -0
- novel_downloader/core/searchers/ixdzs8.py +107 -0
- novel_downloader/core/searchers/jpxs123.py +107 -0
- novel_downloader/core/searchers/piaotia.py +100 -0
- novel_downloader/core/searchers/qbtr.py +106 -0
- novel_downloader/core/searchers/qianbi.py +74 -40
- novel_downloader/core/searchers/quanben5.py +144 -0
- novel_downloader/core/searchers/registry.py +24 -8
- novel_downloader/core/searchers/shuhaige.py +124 -0
- novel_downloader/core/searchers/tongrenquan.py +110 -0
- novel_downloader/core/searchers/ttkan.py +92 -0
- novel_downloader/core/searchers/xiaoshuowu.py +122 -0
- novel_downloader/core/searchers/xiguashuwu.py +95 -0
- novel_downloader/core/searchers/xs63b.py +104 -0
- novel_downloader/locales/en.json +34 -85
- novel_downloader/locales/zh.json +35 -86
- novel_downloader/models/__init__.py +21 -22
- novel_downloader/models/book.py +44 -0
- novel_downloader/models/config.py +4 -37
- novel_downloader/models/login.py +1 -1
- novel_downloader/models/search.py +5 -0
- novel_downloader/resources/config/settings.toml +8 -70
- novel_downloader/resources/json/xiguashuwu.json +718 -0
- novel_downloader/utils/__init__.py +13 -24
- novel_downloader/utils/chapter_storage.py +5 -5
- novel_downloader/utils/constants.py +4 -31
- novel_downloader/utils/cookies.py +38 -35
- novel_downloader/utils/crypto_utils/__init__.py +7 -0
- novel_downloader/utils/crypto_utils/aes_util.py +90 -0
- novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
- novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
- novel_downloader/utils/crypto_utils/rc4.py +54 -0
- novel_downloader/utils/epub/__init__.py +3 -4
- novel_downloader/utils/epub/builder.py +6 -6
- novel_downloader/utils/epub/constants.py +62 -21
- novel_downloader/utils/epub/documents.py +95 -201
- novel_downloader/utils/epub/models.py +8 -22
- novel_downloader/utils/epub/utils.py +73 -106
- novel_downloader/utils/file_utils/__init__.py +2 -23
- novel_downloader/utils/file_utils/io.py +53 -188
- novel_downloader/utils/file_utils/normalize.py +1 -7
- novel_downloader/utils/file_utils/sanitize.py +4 -15
- novel_downloader/utils/fontocr/__init__.py +5 -14
- novel_downloader/utils/fontocr/core.py +216 -0
- novel_downloader/utils/fontocr/loader.py +50 -0
- novel_downloader/utils/logger.py +81 -65
- novel_downloader/utils/network.py +17 -41
- novel_downloader/utils/state.py +4 -90
- novel_downloader/utils/text_utils/__init__.py +1 -7
- novel_downloader/utils/text_utils/diff_display.py +5 -7
- novel_downloader/utils/text_utils/text_cleaner.py +39 -30
- novel_downloader/utils/text_utils/truncate_utils.py +3 -14
- novel_downloader/utils/time_utils/__init__.py +5 -11
- novel_downloader/utils/time_utils/datetime_utils.py +20 -29
- novel_downloader/utils/time_utils/sleep_utils.py +55 -49
- novel_downloader/web/__init__.py +13 -0
- novel_downloader/web/components/__init__.py +11 -0
- novel_downloader/web/components/navigation.py +35 -0
- novel_downloader/web/main.py +66 -0
- novel_downloader/web/pages/__init__.py +17 -0
- novel_downloader/web/pages/download.py +78 -0
- novel_downloader/web/pages/progress.py +147 -0
- novel_downloader/web/pages/search.py +329 -0
- novel_downloader/web/services/__init__.py +17 -0
- novel_downloader/web/services/client_dialog.py +164 -0
- novel_downloader/web/services/cred_broker.py +113 -0
- novel_downloader/web/services/cred_models.py +35 -0
- novel_downloader/web/services/task_manager.py +264 -0
- novel_downloader-2.0.1.dist-info/METADATA +172 -0
- novel_downloader-2.0.1.dist-info/RECORD +206 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/entry_points.txt +1 -1
- novel_downloader/core/downloaders/biquge.py +0 -29
- novel_downloader/core/downloaders/esjzone.py +0 -29
- novel_downloader/core/downloaders/linovelib.py +0 -29
- novel_downloader/core/downloaders/sfacg.py +0 -29
- novel_downloader/core/downloaders/yamibo.py +0 -29
- novel_downloader/core/exporters/biquge.py +0 -22
- novel_downloader/core/exporters/esjzone.py +0 -22
- novel_downloader/core/exporters/qianbi.py +0 -22
- novel_downloader/core/exporters/sfacg.py +0 -22
- novel_downloader/core/exporters/yamibo.py +0 -22
- novel_downloader/core/fetchers/base/__init__.py +0 -14
- novel_downloader/core/fetchers/base/browser.py +0 -422
- novel_downloader/core/fetchers/biquge/__init__.py +0 -14
- novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
- novel_downloader/core/fetchers/esjzone/browser.py +0 -209
- novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
- novel_downloader/core/fetchers/linovelib/browser.py +0 -198
- novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
- novel_downloader/core/fetchers/qidian/__init__.py +0 -14
- novel_downloader/core/fetchers/qidian/browser.py +0 -326
- novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
- novel_downloader/core/fetchers/sfacg/browser.py +0 -194
- novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
- novel_downloader/core/fetchers/yamibo/browser.py +0 -234
- novel_downloader/core/parsers/biquge.py +0 -139
- novel_downloader/core/parsers/qidian/book_info_parser.py +0 -90
- novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -528
- novel_downloader/core/parsers/qidian/chapter_normal.py +0 -157
- novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
- novel_downloader/core/parsers/qidian/utils/helpers.py +0 -114
- novel_downloader/models/chapter.py +0 -25
- novel_downloader/models/types.py +0 -13
- novel_downloader/tui/__init__.py +0 -7
- novel_downloader/tui/app.py +0 -32
- novel_downloader/tui/main.py +0 -17
- novel_downloader/tui/screens/__init__.py +0 -14
- novel_downloader/tui/screens/home.py +0 -198
- novel_downloader/tui/screens/login.py +0 -74
- novel_downloader/tui/styles/home_layout.tcss +0 -79
- novel_downloader/tui/widgets/richlog_handler.py +0 -24
- novel_downloader/utils/cache.py +0 -24
- novel_downloader/utils/crypto_utils.py +0 -71
- novel_downloader/utils/fontocr/hash_store.py +0 -280
- novel_downloader/utils/fontocr/hash_utils.py +0 -103
- novel_downloader/utils/fontocr/model_loader.py +0 -69
- novel_downloader/utils/fontocr/ocr_v1.py +0 -315
- novel_downloader/utils/fontocr/ocr_v2.py +0 -764
- novel_downloader/utils/fontocr/ocr_v3.py +0 -744
- novel_downloader-1.5.0.dist-info/METADATA +0 -196
- novel_downloader-1.5.0.dist-info/RECORD +0 -164
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/WHEEL +0 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/top_level.txt +0 -0
@@ -3,9 +3,7 @@
|
|
3
3
|
novel_downloader.core.exporters.base
|
4
4
|
------------------------------------
|
5
5
|
|
6
|
-
|
7
|
-
the common interface and reusable logic for saving assembled novel
|
8
|
-
content into various output formats.
|
6
|
+
Abstract base class providing common structure and utilities for book exporters
|
9
7
|
"""
|
10
8
|
|
11
9
|
import abc
|
@@ -14,10 +12,10 @@ import logging
|
|
14
12
|
import types
|
15
13
|
from datetime import datetime
|
16
14
|
from pathlib import Path
|
17
|
-
from typing import Any, Self
|
15
|
+
from typing import Any, Self, cast
|
18
16
|
|
19
17
|
from novel_downloader.core.interfaces import ExporterProtocol
|
20
|
-
from novel_downloader.models import ChapterDict, ExporterConfig
|
18
|
+
from novel_downloader.models import BookInfoDict, ChapterDict, ExporterConfig
|
21
19
|
from novel_downloader.utils import ChapterStorage
|
22
20
|
|
23
21
|
|
@@ -34,7 +32,7 @@ class BaseExporter(ExporterProtocol, abc.ABC):
|
|
34
32
|
"""
|
35
33
|
|
36
34
|
DEFAULT_SOURCE_ID = 0
|
37
|
-
|
35
|
+
PRIORITIES_MAP = {
|
38
36
|
DEFAULT_SOURCE_ID: 0,
|
39
37
|
}
|
40
38
|
|
@@ -42,20 +40,15 @@ class BaseExporter(ExporterProtocol, abc.ABC):
|
|
42
40
|
self,
|
43
41
|
config: ExporterConfig,
|
44
42
|
site: str,
|
45
|
-
priorities: dict[int, int] | None = None,
|
46
43
|
):
|
47
44
|
"""
|
48
45
|
Initialize the exporter with given configuration.
|
49
46
|
|
50
47
|
:param config: Exporter configuration settings.
|
51
48
|
:param site: Identifier for the target website or source.
|
52
|
-
:param priorities: Mapping of source_id to priority value.
|
53
|
-
Lower numbers indicate higher priority.
|
54
|
-
E.X. {0: 10, 1: 100} means source 0 is preferred.
|
55
49
|
"""
|
56
50
|
self._config = config
|
57
51
|
self._site = site
|
58
|
-
self._priorities = priorities or self.DEFAULT_PRIORITIES_MAP
|
59
52
|
self._storage_cache: dict[str, ChapterStorage] = {}
|
60
53
|
|
61
54
|
self._raw_data_dir = Path(config.raw_data_dir) / site
|
@@ -64,50 +57,53 @@ class BaseExporter(ExporterProtocol, abc.ABC):
|
|
64
57
|
|
65
58
|
self.logger = logging.getLogger(f"{self.__class__.__name__}")
|
66
59
|
|
67
|
-
def export(
|
68
|
-
self,
|
69
|
-
book_id: str,
|
70
|
-
) -> None:
|
60
|
+
def export(self, book_id: str) -> dict[str, Path]:
|
71
61
|
"""
|
72
62
|
Export the book in the formats specified in config.
|
73
|
-
If a method is not implemented or fails, log the error and continue.
|
74
63
|
|
75
64
|
:param book_id: The book identifier (used for filename, lookup, etc.)
|
76
65
|
"""
|
77
66
|
TAG = "[Exporter]"
|
67
|
+
results: dict[str, Path] = {}
|
68
|
+
|
78
69
|
actions = [
|
79
|
-
("make_txt", self.export_as_txt),
|
80
|
-
("make_epub", self.export_as_epub),
|
81
|
-
("make_md", self.export_as_md),
|
82
|
-
("make_pdf", self.export_as_pdf),
|
70
|
+
("make_txt", "txt", self.export_as_txt),
|
71
|
+
("make_epub", "epub", self.export_as_epub),
|
72
|
+
("make_md", "md", self.export_as_md),
|
73
|
+
("make_pdf", "pdf", self.export_as_pdf),
|
83
74
|
]
|
84
75
|
|
85
|
-
for flag_name, export_method in actions:
|
76
|
+
for flag_name, fmt_key, export_method in actions:
|
86
77
|
if getattr(self._config, flag_name, False):
|
87
78
|
try:
|
88
79
|
self.logger.info(
|
89
80
|
"%s Attempting to export book_id '%s' as %s...",
|
90
81
|
TAG,
|
91
82
|
book_id,
|
92
|
-
|
83
|
+
fmt_key,
|
93
84
|
)
|
94
|
-
export_method(book_id)
|
95
|
-
|
85
|
+
path = export_method(book_id)
|
86
|
+
|
87
|
+
if isinstance(path, Path):
|
88
|
+
results[fmt_key] = path
|
89
|
+
self.logger.info("%s Successfully saved as %s.", TAG, fmt_key)
|
90
|
+
|
96
91
|
except NotImplementedError as e:
|
97
92
|
self.logger.warning(
|
98
93
|
"%s Export method for %s not implemented: %s",
|
99
94
|
TAG,
|
100
|
-
|
95
|
+
fmt_key,
|
101
96
|
str(e),
|
102
97
|
)
|
103
98
|
except Exception as e:
|
104
99
|
self.logger.error(
|
105
|
-
"%s Error while saving as %s: %s", TAG,
|
100
|
+
"%s Error while saving as %s: %s", TAG, fmt_key, str(e)
|
106
101
|
)
|
107
|
-
|
102
|
+
|
103
|
+
return results
|
108
104
|
|
109
105
|
@abc.abstractmethod
|
110
|
-
def export_as_txt(self, book_id: str) -> None:
|
106
|
+
def export_as_txt(self, book_id: str) -> Path | None:
|
111
107
|
"""
|
112
108
|
Persist the assembled book as a .txt file.
|
113
109
|
|
@@ -117,7 +113,7 @@ class BaseExporter(ExporterProtocol, abc.ABC):
|
|
117
113
|
"""
|
118
114
|
...
|
119
115
|
|
120
|
-
def export_as_epub(self, book_id: str) -> None:
|
116
|
+
def export_as_epub(self, book_id: str) -> Path | None:
|
121
117
|
"""
|
122
118
|
Optional: Persist the assembled book as a EPUB (.epub) file.
|
123
119
|
|
@@ -126,7 +122,7 @@ class BaseExporter(ExporterProtocol, abc.ABC):
|
|
126
122
|
"""
|
127
123
|
raise NotImplementedError("EPUB export not supported by this Exporter.")
|
128
124
|
|
129
|
-
def export_as_md(self, book_id: str) -> None:
|
125
|
+
def export_as_md(self, book_id: str) -> Path | None:
|
130
126
|
"""
|
131
127
|
Optional: Persist the assembled book as a Markdown file.
|
132
128
|
|
@@ -135,7 +131,7 @@ class BaseExporter(ExporterProtocol, abc.ABC):
|
|
135
131
|
"""
|
136
132
|
raise NotImplementedError("Markdown export not supported by this Exporter.")
|
137
133
|
|
138
|
-
def export_as_pdf(self, book_id: str) -> None:
|
134
|
+
def export_as_pdf(self, book_id: str) -> Path | None:
|
139
135
|
"""
|
140
136
|
Optional: Persist the assembled book as a PDF file.
|
141
137
|
|
@@ -212,11 +208,11 @@ class BaseExporter(ExporterProtocol, abc.ABC):
|
|
212
208
|
return {}
|
213
209
|
return self._storage_cache[book_id].get_best_chapters(chap_ids)
|
214
210
|
|
215
|
-
def _load_book_info(self, book_id: str) ->
|
211
|
+
def _load_book_info(self, book_id: str) -> BookInfoDict | None:
|
216
212
|
info_path = self._raw_data_dir / book_id / "book_info.json"
|
217
213
|
if not info_path.is_file():
|
218
214
|
self.logger.error("Missing metadata file: %s", info_path)
|
219
|
-
return
|
215
|
+
return None
|
220
216
|
|
221
217
|
try:
|
222
218
|
text = info_path.read_text(encoding="utf-8")
|
@@ -226,18 +222,18 @@ class BaseExporter(ExporterProtocol, abc.ABC):
|
|
226
222
|
"Invalid JSON structure in %s: expected an object at the top",
|
227
223
|
info_path,
|
228
224
|
)
|
229
|
-
return
|
230
|
-
return data
|
225
|
+
return None
|
226
|
+
return cast(BookInfoDict, data)
|
231
227
|
except json.JSONDecodeError as e:
|
232
228
|
self.logger.error("Corrupt JSON in %s: %s", info_path, e)
|
233
|
-
return
|
229
|
+
return None
|
234
230
|
|
235
231
|
def _init_chapter_storages(self, book_id: str) -> None:
|
236
232
|
if book_id in self._storage_cache:
|
237
233
|
return
|
238
234
|
self._storage_cache[book_id] = ChapterStorage(
|
239
235
|
raw_base=self._raw_data_dir / book_id,
|
240
|
-
priorities=self.
|
236
|
+
priorities=self.PRIORITIES_MAP,
|
241
237
|
)
|
242
238
|
self._storage_cache[book_id].connect()
|
243
239
|
|
@@ -3,8 +3,7 @@
|
|
3
3
|
novel_downloader.core.exporters.common
|
4
4
|
--------------------------------------
|
5
5
|
|
6
|
-
|
7
|
-
handling the saving process of novels.
|
6
|
+
Shared exporter implementation for producing standard TXT and EPUB outputs.
|
8
7
|
"""
|
9
8
|
|
10
9
|
__all__ = ["CommonExporter"]
|
@@ -17,6 +17,7 @@ from novel_downloader.core.exporters.epub_util import (
|
|
17
17
|
finalize_export,
|
18
18
|
inline_remote_images,
|
19
19
|
prepare_builder,
|
20
|
+
remove_all_images,
|
20
21
|
)
|
21
22
|
from novel_downloader.utils import (
|
22
23
|
download,
|
@@ -35,7 +36,7 @@ if TYPE_CHECKING:
|
|
35
36
|
def common_export_as_epub(
|
36
37
|
exporter: CommonExporter,
|
37
38
|
book_id: str,
|
38
|
-
) -> None:
|
39
|
+
) -> Path | None:
|
39
40
|
"""
|
40
41
|
Export a single novel (identified by `book_id`) to an EPUB file.
|
41
42
|
|
@@ -67,7 +68,7 @@ def common_export_as_epub(
|
|
67
68
|
# --- Load book_info.json ---
|
68
69
|
book_info = exporter._load_book_info(book_id)
|
69
70
|
if not book_info:
|
70
|
-
return
|
71
|
+
return None
|
71
72
|
|
72
73
|
book_name = book_info.get("book_name", book_id)
|
73
74
|
book_author = book_info.get("author", "")
|
@@ -91,7 +92,7 @@ def common_export_as_epub(
|
|
91
92
|
title=book_name,
|
92
93
|
author=book_author,
|
93
94
|
description=book_info.get("summary", ""),
|
94
|
-
subject=book_info.get("
|
95
|
+
subject=book_info.get("tags", []),
|
95
96
|
serial_status=book_info.get("serial_status", ""),
|
96
97
|
word_count=book_info.get("word_count", ""),
|
97
98
|
cover_path=cover_path,
|
@@ -110,7 +111,7 @@ def common_export_as_epub(
|
|
110
111
|
|
111
112
|
# Batch-fetch chapters for this volume
|
112
113
|
chap_ids = [
|
113
|
-
chap
|
114
|
+
chap["chapterId"]
|
114
115
|
for chap in vol.get("chapters", [])
|
115
116
|
if chap.get("chapterId")
|
116
117
|
]
|
@@ -143,7 +144,7 @@ def common_export_as_epub(
|
|
143
144
|
)
|
144
145
|
continue
|
145
146
|
|
146
|
-
chap_title =
|
147
|
+
chap_title = chap_meta.get("title", "")
|
147
148
|
data = chap_map.get(chap_id)
|
148
149
|
if not data:
|
149
150
|
exporter.logger.info(
|
@@ -158,14 +159,19 @@ def common_export_as_epub(
|
|
158
159
|
content = cleaner.clean_content(data.get("content", ""))
|
159
160
|
extra = data.get("extra", {})
|
160
161
|
author_note = cleaner.clean_content(extra.get("author_say", ""))
|
161
|
-
content =
|
162
|
+
content = (
|
163
|
+
inline_remote_images(book, content, img_dir)
|
164
|
+
if config.include_picture
|
165
|
+
else remove_all_images(content)
|
166
|
+
)
|
167
|
+
extras = {"作者说": author_note} if author_note else {}
|
162
168
|
|
163
169
|
chap_html = build_epub_chapter(
|
164
170
|
title=title,
|
165
171
|
paragraphs=content,
|
166
|
-
extras=
|
172
|
+
extras=extras,
|
167
173
|
)
|
168
|
-
curr_vol.
|
174
|
+
curr_vol.chapters.append(
|
169
175
|
Chapter(
|
170
176
|
id=f"c_{chap_id}",
|
171
177
|
filename=f"c{chap_id}.xhtml",
|
@@ -183,11 +189,10 @@ def common_export_as_epub(
|
|
183
189
|
author=book_info.get("author"),
|
184
190
|
ext="epub",
|
185
191
|
)
|
186
|
-
finalize_export(
|
192
|
+
return finalize_export(
|
187
193
|
book=book,
|
188
194
|
out_dir=out_dir,
|
189
195
|
filename=out_name,
|
190
196
|
logger=exporter.logger,
|
191
197
|
tag=TAG,
|
192
198
|
)
|
193
|
-
return
|
@@ -3,25 +3,27 @@
|
|
3
3
|
novel_downloader.core.exporters.common.main_exporter
|
4
4
|
----------------------------------------------------
|
5
5
|
|
6
|
-
|
7
|
-
novel data. It defines the logic to compile, structure, and export novel content
|
8
|
-
in plain text format based on the platform's metadata and chapter files.
|
6
|
+
Common exporter implementation for saving novels as TXT and EPUB files.
|
9
7
|
"""
|
10
8
|
|
9
|
+
from pathlib import Path
|
10
|
+
|
11
11
|
from novel_downloader.core.exporters.base import BaseExporter
|
12
12
|
|
13
|
+
from .epub import common_export_as_epub
|
13
14
|
from .txt import common_export_as_txt
|
14
15
|
|
15
16
|
|
16
17
|
class CommonExporter(BaseExporter):
|
17
18
|
"""
|
18
19
|
CommonExporter is a exporter that processes and exports novels.
|
20
|
+
|
19
21
|
It extends the BaseExporter interface and provides
|
20
22
|
logic for exporting full novels as plain text (.txt) files
|
21
23
|
and EPUB (.epub) files.
|
22
24
|
"""
|
23
25
|
|
24
|
-
def export_as_txt(self, book_id: str) -> None:
|
26
|
+
def export_as_txt(self, book_id: str) -> Path | None:
|
25
27
|
"""
|
26
28
|
Compile and export a complete novel as a single .txt file.
|
27
29
|
|
@@ -36,22 +38,27 @@ class CommonExporter(BaseExporter):
|
|
36
38
|
|
37
39
|
:param book_id: The book identifier (used to locate raw data)
|
38
40
|
"""
|
41
|
+
book_id = self._normalize_book_id(book_id)
|
39
42
|
self._init_chapter_storages(book_id)
|
40
43
|
return common_export_as_txt(self, book_id)
|
41
44
|
|
42
|
-
def export_as_epub(self, book_id: str) -> None:
|
45
|
+
def export_as_epub(self, book_id: str) -> Path | None:
|
43
46
|
"""
|
44
47
|
Persist the assembled book as a EPUB (.epub) file.
|
45
48
|
|
46
49
|
:param book_id: The book identifier.
|
47
50
|
:raises NotImplementedError: If the method is not overridden.
|
48
51
|
"""
|
49
|
-
|
50
|
-
from .epub import common_export_as_epub
|
51
|
-
except ImportError as err:
|
52
|
-
raise NotImplementedError(
|
53
|
-
"EPUB export not supported. Please install 'ebooklib'"
|
54
|
-
) from err
|
55
|
-
|
52
|
+
book_id = self._normalize_book_id(book_id)
|
56
53
|
self._init_chapter_storages(book_id)
|
57
54
|
return common_export_as_epub(self, book_id)
|
55
|
+
|
56
|
+
@staticmethod
|
57
|
+
def _normalize_book_id(book_id: str) -> str:
|
58
|
+
"""
|
59
|
+
Normalize a book identifier.
|
60
|
+
|
61
|
+
Subclasses may override this method to transform the book ID
|
62
|
+
into their preferred format.
|
63
|
+
"""
|
64
|
+
return book_id.replace("/", "-")
|
@@ -9,13 +9,14 @@ into a single `.txt` file. Intended for use by `CommonExporter`.
|
|
9
9
|
|
10
10
|
from __future__ import annotations
|
11
11
|
|
12
|
+
from pathlib import Path
|
12
13
|
from typing import TYPE_CHECKING
|
13
14
|
|
14
15
|
from novel_downloader.core.exporters.txt_util import (
|
15
16
|
build_txt_chapter,
|
16
17
|
build_txt_header,
|
17
18
|
)
|
18
|
-
from novel_downloader.utils import get_cleaner,
|
19
|
+
from novel_downloader.utils import get_cleaner, write_file
|
19
20
|
|
20
21
|
if TYPE_CHECKING:
|
21
22
|
from .main_exporter import CommonExporter
|
@@ -24,13 +25,12 @@ if TYPE_CHECKING:
|
|
24
25
|
def common_export_as_txt(
|
25
26
|
exporter: CommonExporter,
|
26
27
|
book_id: str,
|
27
|
-
) -> None:
|
28
|
+
) -> Path | None:
|
28
29
|
"""
|
29
30
|
Export a novel as a single text file by merging all chapter data.
|
30
31
|
|
31
32
|
Steps:
|
32
|
-
1. Load book metadata
|
33
|
-
volumes, and chapters).
|
33
|
+
1. Load book metadata.
|
34
34
|
2. For each volume:
|
35
35
|
a. Append the volume title.
|
36
36
|
b. Batch-fetch all chapters in that volume to minimize SQLite calls.
|
@@ -55,7 +55,7 @@ def common_export_as_txt(
|
|
55
55
|
# --- Load book_info.json ---
|
56
56
|
book_info = exporter._load_book_info(book_id)
|
57
57
|
if not book_info:
|
58
|
-
return
|
58
|
+
return None
|
59
59
|
|
60
60
|
# --- Compile chapters ---
|
61
61
|
parts: list[str] = []
|
@@ -70,7 +70,7 @@ def common_export_as_txt(
|
|
70
70
|
|
71
71
|
# Batch-fetch chapters for this volume
|
72
72
|
chap_ids = [
|
73
|
-
chap
|
73
|
+
chap["chapterId"]
|
74
74
|
for chap in vol.get("chapters", [])
|
75
75
|
if chap.get("chapterId")
|
76
76
|
]
|
@@ -84,7 +84,7 @@ def common_export_as_txt(
|
|
84
84
|
)
|
85
85
|
continue
|
86
86
|
|
87
|
-
chap_title =
|
87
|
+
chap_title = chap_meta.get("title", "")
|
88
88
|
data = chap_map.get(chap_id)
|
89
89
|
if not data:
|
90
90
|
exporter.logger.info(
|
@@ -133,9 +133,14 @@ def common_export_as_txt(
|
|
133
133
|
out_path = out_dir / out_name
|
134
134
|
|
135
135
|
# --- Save final text ---
|
136
|
-
|
137
|
-
|
136
|
+
try:
|
137
|
+
result = write_file(
|
138
|
+
content=final_text,
|
139
|
+
filepath=out_path,
|
140
|
+
on_exist="overwrite",
|
141
|
+
)
|
138
142
|
exporter.logger.info("%s Novel saved to: %s", TAG, out_path)
|
139
|
-
|
140
|
-
exporter.logger.error("%s Failed to write novel to %s", TAG, out_path)
|
141
|
-
|
143
|
+
except Exception as e:
|
144
|
+
exporter.logger.error("%s Failed to write novel to %s: %s", TAG, out_path, e)
|
145
|
+
return None
|
146
|
+
return result
|
@@ -11,12 +11,13 @@ __all__ = [
|
|
11
11
|
"prepare_builder",
|
12
12
|
"finalize_export",
|
13
13
|
"inline_remote_images",
|
14
|
+
"remove_all_images",
|
14
15
|
"build_epub_chapter",
|
15
16
|
]
|
16
17
|
|
17
|
-
import html
|
18
18
|
import logging
|
19
19
|
import re
|
20
|
+
from html import escape
|
20
21
|
from pathlib import Path
|
21
22
|
|
22
23
|
from novel_downloader.utils import download, sanitize_filename
|
@@ -27,14 +28,11 @@ from novel_downloader.utils.constants import (
|
|
27
28
|
)
|
28
29
|
from novel_downloader.utils.epub import EpubBuilder, StyleSheet
|
29
30
|
|
30
|
-
_IMAGE_WRAPPER =
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
)
|
36
|
-
_RAW_HTML_RE = re.compile(
|
37
|
-
r'^(<img\b[^>]*?\/>|<div class="duokan-image-single illus">.*?<\/div>)$', re.DOTALL
|
31
|
+
_IMAGE_WRAPPER = '<div class="duokan-image-single illus">{img}</div>'
|
32
|
+
_IMG_TAG_RE = re.compile(r"<img[^>]*>", re.IGNORECASE)
|
33
|
+
_IMG_SRC_RE = re.compile(
|
34
|
+
r'<img[^>]*\bsrc=["\'](https?://[^"\']+)["\'][^>]*>',
|
35
|
+
re.IGNORECASE,
|
38
36
|
)
|
39
37
|
|
40
38
|
|
@@ -94,13 +92,15 @@ def finalize_export(
|
|
94
92
|
filename: str,
|
95
93
|
logger: logging.Logger,
|
96
94
|
tag: str,
|
97
|
-
) -> None:
|
95
|
+
) -> Path | None:
|
98
96
|
out_path = out_dir / sanitize_filename(filename)
|
99
97
|
try:
|
100
98
|
book.export(out_path)
|
101
99
|
logger.info("%s EPUB successfully written to %s", tag, out_path)
|
100
|
+
return out_path
|
102
101
|
except OSError as e:
|
103
102
|
logger.error("%s Failed to write EPUB to %s: %s", tag, out_path, e)
|
103
|
+
return None
|
104
104
|
|
105
105
|
|
106
106
|
def inline_remote_images(
|
@@ -111,15 +111,15 @@ def inline_remote_images(
|
|
111
111
|
) -> str:
|
112
112
|
"""
|
113
113
|
Download every remote `<img src="...">` in `content` into `image_dir`,
|
114
|
-
and replace the original
|
114
|
+
and replace the original url with local path.
|
115
115
|
|
116
116
|
:param content: HTML/text of the chapter containing <img> tags.
|
117
117
|
:param image_dir: Directory to save downloaded images into.
|
118
118
|
:return: modified_content.
|
119
119
|
"""
|
120
120
|
|
121
|
-
def _replace(
|
122
|
-
url =
|
121
|
+
def _replace(m: re.Match[str]) -> str:
|
122
|
+
url = m.group(1)
|
123
123
|
try:
|
124
124
|
local_path = download(
|
125
125
|
url,
|
@@ -129,14 +129,22 @@ def inline_remote_images(
|
|
129
129
|
default_suffix=DEFAULT_IMAGE_SUFFIX,
|
130
130
|
)
|
131
131
|
if not local_path:
|
132
|
-
return
|
132
|
+
return m.group(0)
|
133
133
|
filename = book.add_image(local_path)
|
134
|
-
return
|
134
|
+
return f'<img src="../Images/{filename}" />'
|
135
135
|
except Exception:
|
136
|
-
return
|
136
|
+
return m.group(0)
|
137
|
+
|
138
|
+
return _IMG_SRC_RE.sub(_replace, content)
|
137
139
|
|
138
|
-
|
139
|
-
|
140
|
+
|
141
|
+
def remove_all_images(content: str) -> str:
|
142
|
+
"""
|
143
|
+
Remove all <img> tags from the given content.
|
144
|
+
|
145
|
+
:param content: HTML/text of the chapter containing <img> tags.
|
146
|
+
"""
|
147
|
+
return _IMG_TAG_RE.sub("", content)
|
140
148
|
|
141
149
|
|
142
150
|
def build_epub_chapter(
|
@@ -148,25 +156,47 @@ def build_epub_chapter(
|
|
148
156
|
Build a formatted chapter epub HTML including title, body paragraphs,
|
149
157
|
and optional extra sections.
|
150
158
|
|
151
|
-
:param title:
|
159
|
+
:param title: Chapter title.
|
152
160
|
:param paragraphs: Raw multi-line string. Blank lines are ignored.
|
153
|
-
:param extras:
|
154
|
-
:return:
|
161
|
+
:param extras: Optional dict mapping section titles to multi-line strings.
|
162
|
+
:return: A HTML include title, paragraphs, and extras.
|
155
163
|
"""
|
156
164
|
|
157
165
|
def _render_block(text: str) -> str:
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
166
|
+
out: list[str] = []
|
167
|
+
for raw in text.splitlines():
|
168
|
+
line = raw.strip()
|
169
|
+
if not line:
|
170
|
+
continue
|
171
|
+
|
172
|
+
# case 1: already wrapped in a <div>...</div>
|
173
|
+
if line.startswith("<div") and line.endswith("</div>"):
|
163
174
|
out.append(line)
|
175
|
+
continue
|
176
|
+
|
177
|
+
# case 2: single <img> line
|
178
|
+
if _IMG_TAG_RE.fullmatch(line):
|
179
|
+
out.append(_IMAGE_WRAPPER.format(img=line))
|
180
|
+
continue
|
181
|
+
|
182
|
+
# case 3: inline <img> in text -> escape other text, preserve <img>
|
183
|
+
if "<img " in line:
|
184
|
+
pieces = []
|
185
|
+
last = 0
|
186
|
+
for m in _IMG_TAG_RE.finditer(line):
|
187
|
+
pieces.append(escape(line[last : m.start()]))
|
188
|
+
pieces.append(m.group(0))
|
189
|
+
last = m.end()
|
190
|
+
pieces.append(escape(line[last:]))
|
191
|
+
out.append("<p>" + "".join(pieces) + "</p>")
|
164
192
|
else:
|
165
|
-
|
193
|
+
# plain text line
|
194
|
+
out.append(f"<p>{escape(line)}</p>")
|
195
|
+
|
166
196
|
return "\n".join(out)
|
167
197
|
|
168
198
|
parts = []
|
169
|
-
parts.append(f"<h2>{
|
199
|
+
parts.append(f"<h2>{escape(title)}</h2>")
|
170
200
|
parts.append(_render_block(paragraphs))
|
171
201
|
|
172
202
|
if extras:
|
@@ -177,7 +207,7 @@ def build_epub_chapter(
|
|
177
207
|
parts.extend(
|
178
208
|
[
|
179
209
|
"<hr />",
|
180
|
-
f"<h3>{
|
210
|
+
f"<h3>{escape(title)}</h3>",
|
181
211
|
_render_block(note),
|
182
212
|
]
|
183
213
|
)
|