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.
Files changed (248) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/__init__.py +1 -3
  3. novel_downloader/cli/clean.py +21 -88
  4. novel_downloader/cli/config.py +26 -21
  5. novel_downloader/cli/download.py +79 -66
  6. novel_downloader/cli/export.py +17 -21
  7. novel_downloader/cli/main.py +1 -1
  8. novel_downloader/cli/search.py +62 -65
  9. novel_downloader/cli/ui.py +156 -0
  10. novel_downloader/config/__init__.py +8 -5
  11. novel_downloader/config/adapter.py +206 -209
  12. novel_downloader/config/{loader.py → file_io.py} +53 -26
  13. novel_downloader/core/__init__.py +5 -5
  14. novel_downloader/core/archived/deqixs/fetcher.py +115 -0
  15. novel_downloader/core/archived/deqixs/parser.py +132 -0
  16. novel_downloader/core/archived/deqixs/searcher.py +89 -0
  17. novel_downloader/core/{searchers/qidian.py → archived/qidian/searcher.py} +12 -20
  18. novel_downloader/core/archived/wanbengo/searcher.py +98 -0
  19. novel_downloader/core/archived/xshbook/searcher.py +93 -0
  20. novel_downloader/core/downloaders/__init__.py +3 -24
  21. novel_downloader/core/downloaders/base.py +49 -23
  22. novel_downloader/core/downloaders/common.py +191 -137
  23. novel_downloader/core/downloaders/qianbi.py +187 -146
  24. novel_downloader/core/downloaders/qidian.py +187 -141
  25. novel_downloader/core/downloaders/registry.py +4 -2
  26. novel_downloader/core/downloaders/signals.py +46 -0
  27. novel_downloader/core/exporters/__init__.py +3 -20
  28. novel_downloader/core/exporters/base.py +33 -37
  29. novel_downloader/core/exporters/common/__init__.py +1 -2
  30. novel_downloader/core/exporters/common/epub.py +15 -10
  31. novel_downloader/core/exporters/common/main_exporter.py +19 -12
  32. novel_downloader/core/exporters/common/txt.py +17 -12
  33. novel_downloader/core/exporters/epub_util.py +59 -29
  34. novel_downloader/core/exporters/linovelib/__init__.py +1 -0
  35. novel_downloader/core/exporters/linovelib/epub.py +23 -25
  36. novel_downloader/core/exporters/linovelib/main_exporter.py +8 -12
  37. novel_downloader/core/exporters/linovelib/txt.py +20 -14
  38. novel_downloader/core/exporters/qidian.py +2 -8
  39. novel_downloader/core/exporters/registry.py +4 -2
  40. novel_downloader/core/exporters/txt_util.py +7 -7
  41. novel_downloader/core/fetchers/__init__.py +54 -48
  42. novel_downloader/core/fetchers/aaatxt.py +83 -0
  43. novel_downloader/core/fetchers/{biquge/session.py → b520.py} +6 -11
  44. novel_downloader/core/fetchers/{base/session.py → base.py} +37 -46
  45. novel_downloader/core/fetchers/{biquge/browser.py → biquyuedu.py} +12 -17
  46. novel_downloader/core/fetchers/dxmwx.py +110 -0
  47. novel_downloader/core/fetchers/eightnovel.py +139 -0
  48. novel_downloader/core/fetchers/{esjzone/session.py → esjzone.py} +19 -12
  49. novel_downloader/core/fetchers/guidaye.py +85 -0
  50. novel_downloader/core/fetchers/hetushu.py +92 -0
  51. novel_downloader/core/fetchers/{qianbi/browser.py → i25zw.py} +19 -28
  52. novel_downloader/core/fetchers/ixdzs8.py +113 -0
  53. novel_downloader/core/fetchers/jpxs123.py +101 -0
  54. novel_downloader/core/fetchers/lewenn.py +83 -0
  55. novel_downloader/core/fetchers/{linovelib/session.py → linovelib.py} +12 -13
  56. novel_downloader/core/fetchers/piaotia.py +105 -0
  57. novel_downloader/core/fetchers/qbtr.py +101 -0
  58. novel_downloader/core/fetchers/{qianbi/session.py → qianbi.py} +5 -10
  59. novel_downloader/core/fetchers/{qidian/session.py → qidian.py} +56 -64
  60. novel_downloader/core/fetchers/quanben5.py +92 -0
  61. novel_downloader/core/fetchers/{base/rate_limiter.py → rate_limiter.py} +2 -2
  62. novel_downloader/core/fetchers/registry.py +5 -16
  63. novel_downloader/core/fetchers/{sfacg/session.py → sfacg.py} +7 -10
  64. novel_downloader/core/fetchers/shencou.py +106 -0
  65. novel_downloader/core/fetchers/shuhaige.py +84 -0
  66. novel_downloader/core/fetchers/tongrenquan.py +84 -0
  67. novel_downloader/core/fetchers/ttkan.py +95 -0
  68. novel_downloader/core/fetchers/wanbengo.py +83 -0
  69. novel_downloader/core/fetchers/xiaoshuowu.py +106 -0
  70. novel_downloader/core/fetchers/xiguashuwu.py +177 -0
  71. novel_downloader/core/fetchers/xs63b.py +171 -0
  72. novel_downloader/core/fetchers/xshbook.py +85 -0
  73. novel_downloader/core/fetchers/{yamibo/session.py → yamibo.py} +19 -12
  74. novel_downloader/core/fetchers/yibige.py +114 -0
  75. novel_downloader/core/interfaces/__init__.py +1 -9
  76. novel_downloader/core/interfaces/downloader.py +6 -2
  77. novel_downloader/core/interfaces/exporter.py +7 -7
  78. novel_downloader/core/interfaces/fetcher.py +6 -19
  79. novel_downloader/core/interfaces/parser.py +7 -8
  80. novel_downloader/core/interfaces/searcher.py +9 -1
  81. novel_downloader/core/parsers/__init__.py +49 -12
  82. novel_downloader/core/parsers/aaatxt.py +132 -0
  83. novel_downloader/core/parsers/b520.py +116 -0
  84. novel_downloader/core/parsers/base.py +64 -12
  85. novel_downloader/core/parsers/biquyuedu.py +133 -0
  86. novel_downloader/core/parsers/dxmwx.py +162 -0
  87. novel_downloader/core/parsers/eightnovel.py +224 -0
  88. novel_downloader/core/parsers/esjzone.py +64 -69
  89. novel_downloader/core/parsers/guidaye.py +128 -0
  90. novel_downloader/core/parsers/hetushu.py +139 -0
  91. novel_downloader/core/parsers/i25zw.py +137 -0
  92. novel_downloader/core/parsers/ixdzs8.py +186 -0
  93. novel_downloader/core/parsers/jpxs123.py +137 -0
  94. novel_downloader/core/parsers/lewenn.py +142 -0
  95. novel_downloader/core/parsers/linovelib.py +48 -64
  96. novel_downloader/core/parsers/piaotia.py +189 -0
  97. novel_downloader/core/parsers/qbtr.py +136 -0
  98. novel_downloader/core/parsers/qianbi.py +48 -50
  99. novel_downloader/core/parsers/qidian/main_parser.py +756 -48
  100. novel_downloader/core/parsers/qidian/utils/__init__.py +3 -21
  101. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +1 -1
  102. novel_downloader/core/parsers/qidian/utils/node_decryptor.py +4 -4
  103. novel_downloader/core/parsers/quanben5.py +103 -0
  104. novel_downloader/core/parsers/registry.py +5 -16
  105. novel_downloader/core/parsers/sfacg.py +38 -45
  106. novel_downloader/core/parsers/shencou.py +215 -0
  107. novel_downloader/core/parsers/shuhaige.py +111 -0
  108. novel_downloader/core/parsers/tongrenquan.py +116 -0
  109. novel_downloader/core/parsers/ttkan.py +132 -0
  110. novel_downloader/core/parsers/wanbengo.py +191 -0
  111. novel_downloader/core/parsers/xiaoshuowu.py +173 -0
  112. novel_downloader/core/parsers/xiguashuwu.py +429 -0
  113. novel_downloader/core/parsers/xs63b.py +161 -0
  114. novel_downloader/core/parsers/xshbook.py +134 -0
  115. novel_downloader/core/parsers/yamibo.py +87 -131
  116. novel_downloader/core/parsers/yibige.py +166 -0
  117. novel_downloader/core/searchers/__init__.py +34 -3
  118. novel_downloader/core/searchers/aaatxt.py +107 -0
  119. novel_downloader/core/searchers/{biquge.py → b520.py} +29 -28
  120. novel_downloader/core/searchers/base.py +112 -36
  121. novel_downloader/core/searchers/dxmwx.py +105 -0
  122. novel_downloader/core/searchers/eightnovel.py +84 -0
  123. novel_downloader/core/searchers/esjzone.py +43 -25
  124. novel_downloader/core/searchers/hetushu.py +92 -0
  125. novel_downloader/core/searchers/i25zw.py +93 -0
  126. novel_downloader/core/searchers/ixdzs8.py +107 -0
  127. novel_downloader/core/searchers/jpxs123.py +107 -0
  128. novel_downloader/core/searchers/piaotia.py +100 -0
  129. novel_downloader/core/searchers/qbtr.py +106 -0
  130. novel_downloader/core/searchers/qianbi.py +74 -40
  131. novel_downloader/core/searchers/quanben5.py +144 -0
  132. novel_downloader/core/searchers/registry.py +24 -8
  133. novel_downloader/core/searchers/shuhaige.py +124 -0
  134. novel_downloader/core/searchers/tongrenquan.py +110 -0
  135. novel_downloader/core/searchers/ttkan.py +92 -0
  136. novel_downloader/core/searchers/xiaoshuowu.py +122 -0
  137. novel_downloader/core/searchers/xiguashuwu.py +95 -0
  138. novel_downloader/core/searchers/xs63b.py +104 -0
  139. novel_downloader/locales/en.json +34 -85
  140. novel_downloader/locales/zh.json +35 -86
  141. novel_downloader/models/__init__.py +21 -22
  142. novel_downloader/models/book.py +44 -0
  143. novel_downloader/models/config.py +4 -37
  144. novel_downloader/models/login.py +1 -1
  145. novel_downloader/models/search.py +5 -0
  146. novel_downloader/resources/config/settings.toml +8 -70
  147. novel_downloader/resources/json/xiguashuwu.json +718 -0
  148. novel_downloader/utils/__init__.py +13 -24
  149. novel_downloader/utils/chapter_storage.py +5 -5
  150. novel_downloader/utils/constants.py +4 -31
  151. novel_downloader/utils/cookies.py +38 -35
  152. novel_downloader/utils/crypto_utils/__init__.py +7 -0
  153. novel_downloader/utils/crypto_utils/aes_util.py +90 -0
  154. novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
  155. novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
  156. novel_downloader/utils/crypto_utils/rc4.py +54 -0
  157. novel_downloader/utils/epub/__init__.py +3 -4
  158. novel_downloader/utils/epub/builder.py +6 -6
  159. novel_downloader/utils/epub/constants.py +62 -21
  160. novel_downloader/utils/epub/documents.py +95 -201
  161. novel_downloader/utils/epub/models.py +8 -22
  162. novel_downloader/utils/epub/utils.py +73 -106
  163. novel_downloader/utils/file_utils/__init__.py +2 -23
  164. novel_downloader/utils/file_utils/io.py +53 -188
  165. novel_downloader/utils/file_utils/normalize.py +1 -7
  166. novel_downloader/utils/file_utils/sanitize.py +4 -15
  167. novel_downloader/utils/fontocr/__init__.py +5 -14
  168. novel_downloader/utils/fontocr/core.py +216 -0
  169. novel_downloader/utils/fontocr/loader.py +50 -0
  170. novel_downloader/utils/logger.py +81 -65
  171. novel_downloader/utils/network.py +17 -41
  172. novel_downloader/utils/state.py +4 -90
  173. novel_downloader/utils/text_utils/__init__.py +1 -7
  174. novel_downloader/utils/text_utils/diff_display.py +5 -7
  175. novel_downloader/utils/text_utils/text_cleaner.py +39 -30
  176. novel_downloader/utils/text_utils/truncate_utils.py +3 -14
  177. novel_downloader/utils/time_utils/__init__.py +5 -11
  178. novel_downloader/utils/time_utils/datetime_utils.py +20 -29
  179. novel_downloader/utils/time_utils/sleep_utils.py +55 -49
  180. novel_downloader/web/__init__.py +13 -0
  181. novel_downloader/web/components/__init__.py +11 -0
  182. novel_downloader/web/components/navigation.py +35 -0
  183. novel_downloader/web/main.py +66 -0
  184. novel_downloader/web/pages/__init__.py +17 -0
  185. novel_downloader/web/pages/download.py +78 -0
  186. novel_downloader/web/pages/progress.py +147 -0
  187. novel_downloader/web/pages/search.py +329 -0
  188. novel_downloader/web/services/__init__.py +17 -0
  189. novel_downloader/web/services/client_dialog.py +164 -0
  190. novel_downloader/web/services/cred_broker.py +113 -0
  191. novel_downloader/web/services/cred_models.py +35 -0
  192. novel_downloader/web/services/task_manager.py +264 -0
  193. novel_downloader-2.0.1.dist-info/METADATA +172 -0
  194. novel_downloader-2.0.1.dist-info/RECORD +206 -0
  195. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/entry_points.txt +1 -1
  196. novel_downloader/core/downloaders/biquge.py +0 -29
  197. novel_downloader/core/downloaders/esjzone.py +0 -29
  198. novel_downloader/core/downloaders/linovelib.py +0 -29
  199. novel_downloader/core/downloaders/sfacg.py +0 -29
  200. novel_downloader/core/downloaders/yamibo.py +0 -29
  201. novel_downloader/core/exporters/biquge.py +0 -22
  202. novel_downloader/core/exporters/esjzone.py +0 -22
  203. novel_downloader/core/exporters/qianbi.py +0 -22
  204. novel_downloader/core/exporters/sfacg.py +0 -22
  205. novel_downloader/core/exporters/yamibo.py +0 -22
  206. novel_downloader/core/fetchers/base/__init__.py +0 -14
  207. novel_downloader/core/fetchers/base/browser.py +0 -422
  208. novel_downloader/core/fetchers/biquge/__init__.py +0 -14
  209. novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
  210. novel_downloader/core/fetchers/esjzone/browser.py +0 -209
  211. novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
  212. novel_downloader/core/fetchers/linovelib/browser.py +0 -198
  213. novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
  214. novel_downloader/core/fetchers/qidian/__init__.py +0 -14
  215. novel_downloader/core/fetchers/qidian/browser.py +0 -326
  216. novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
  217. novel_downloader/core/fetchers/sfacg/browser.py +0 -194
  218. novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
  219. novel_downloader/core/fetchers/yamibo/browser.py +0 -234
  220. novel_downloader/core/parsers/biquge.py +0 -139
  221. novel_downloader/core/parsers/qidian/book_info_parser.py +0 -90
  222. novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -528
  223. novel_downloader/core/parsers/qidian/chapter_normal.py +0 -157
  224. novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
  225. novel_downloader/core/parsers/qidian/utils/helpers.py +0 -114
  226. novel_downloader/models/chapter.py +0 -25
  227. novel_downloader/models/types.py +0 -13
  228. novel_downloader/tui/__init__.py +0 -7
  229. novel_downloader/tui/app.py +0 -32
  230. novel_downloader/tui/main.py +0 -17
  231. novel_downloader/tui/screens/__init__.py +0 -14
  232. novel_downloader/tui/screens/home.py +0 -198
  233. novel_downloader/tui/screens/login.py +0 -74
  234. novel_downloader/tui/styles/home_layout.tcss +0 -79
  235. novel_downloader/tui/widgets/richlog_handler.py +0 -24
  236. novel_downloader/utils/cache.py +0 -24
  237. novel_downloader/utils/crypto_utils.py +0 -71
  238. novel_downloader/utils/fontocr/hash_store.py +0 -280
  239. novel_downloader/utils/fontocr/hash_utils.py +0 -103
  240. novel_downloader/utils/fontocr/model_loader.py +0 -69
  241. novel_downloader/utils/fontocr/ocr_v1.py +0 -315
  242. novel_downloader/utils/fontocr/ocr_v2.py +0 -764
  243. novel_downloader/utils/fontocr/ocr_v3.py +0 -744
  244. novel_downloader-1.5.0.dist-info/METADATA +0 -196
  245. novel_downloader-1.5.0.dist-info/RECORD +0 -164
  246. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/WHEEL +0 -0
  247. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/licenses/LICENSE +0 -0
  248. {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
- This module provides an abstract base class `BaseExporter` that defines
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
- DEFAULT_PRIORITIES_MAP = {
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
- flag_name,
83
+ fmt_key,
93
84
  )
94
- export_method(book_id)
95
- self.logger.info("%s Successfully saved as %s.", TAG, flag_name)
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
- flag_name,
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, flag_name, str(e)
100
+ "%s Error while saving as %s: %s", TAG, fmt_key, str(e)
106
101
  )
107
- return
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) -> dict[str, Any]:
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._priorities,
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
- This module provides the `CommonExporter` class for
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("subject", []),
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.get("chapterId")
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 = cleaner.clean_title(chap_meta.get("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 = inline_remote_images(book, content, img_dir)
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={"作者说": author_note},
172
+ extras=extras,
167
173
  )
168
- curr_vol.add_chapter(
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
- This module implements the `CommonExporter` class, a concrete exporter for handling
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
- try:
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, save_as_txt
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 (title, author, summary, word count, update time,
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.get("chapterId")
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 = cleaner.clean_title(chap_meta.get("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
- result = save_as_txt(content=final_text, filepath=out_path)
137
- if result:
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
- else:
140
- exporter.logger.error("%s Failed to write novel to %s", TAG, out_path)
141
- return
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
- '<div class="duokan-image-single illus"><img src="../Images/{filename}" /></div>'
32
- )
33
- _IMG_TAG_PATTERN = re.compile(
34
- r'<img\s+[^>]*src=[\'"]([^\'"]+)[\'"][^>]*>', re.IGNORECASE
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 tag with _IMAGE_WRAPPER.
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(match: re.Match[str]) -> str:
122
- url = match.group(1)
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 match.group(0)
132
+ return m.group(0)
133
133
  filename = book.add_image(local_path)
134
- return _IMAGE_WRAPPER.format(filename=filename)
134
+ return f'<img src="../Images/{filename}" />'
135
135
  except Exception:
136
- return match.group(0)
136
+ return m.group(0)
137
+
138
+ return _IMG_SRC_RE.sub(_replace, content)
137
139
 
138
- modified_content = _IMG_TAG_PATTERN.sub(_replace, content)
139
- return modified_content
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: Chapter title.
159
+ :param title: Chapter title.
152
160
  :param paragraphs: Raw multi-line string. Blank lines are ignored.
153
- :param extras: Optional dict mapping section titles to multi-line strings.
154
- :return: A HTML include title, paragraphs, and extras.
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
- lines = (line.strip() for line in text.splitlines() if line.strip())
159
- out = []
160
- for line in lines:
161
- # preserve raw HTML, otherwise wrap in <p>
162
- if _RAW_HTML_RE.match(line):
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
- out.append(f"<p>{html.escape(line)}</p>")
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>{html.escape(title)}</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>{html.escape(title)}</h3>",
210
+ f"<h3>{escape(title)}</h3>",
181
211
  _render_block(note),
182
212
  ]
183
213
  )
@@ -3,6 +3,7 @@
3
3
  novel_downloader.core.exporters.linovelib
4
4
  -----------------------------------------
5
5
 
6
+ Exporter implementation for handling Linovelib novels.
6
7
  """
7
8
 
8
9
  __all__ = ["LinovelibExporter"]