novel-downloader 1.4.5__py3-none-any.whl → 2.0.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.
Files changed (276) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/__init__.py +2 -4
  3. novel_downloader/cli/clean.py +21 -88
  4. novel_downloader/cli/config.py +27 -104
  5. novel_downloader/cli/download.py +78 -66
  6. novel_downloader/cli/export.py +20 -21
  7. novel_downloader/cli/main.py +3 -1
  8. novel_downloader/cli/search.py +120 -0
  9. novel_downloader/cli/ui.py +156 -0
  10. novel_downloader/config/__init__.py +10 -14
  11. novel_downloader/config/adapter.py +195 -99
  12. novel_downloader/config/{loader.py → file_io.py} +53 -27
  13. novel_downloader/core/__init__.py +14 -13
  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/archived/qidian/searcher.py +79 -0
  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 +8 -30
  21. novel_downloader/core/downloaders/base.py +182 -30
  22. novel_downloader/core/downloaders/common.py +217 -384
  23. novel_downloader/core/downloaders/qianbi.py +332 -4
  24. novel_downloader/core/downloaders/qidian.py +250 -290
  25. novel_downloader/core/downloaders/registry.py +69 -0
  26. novel_downloader/core/downloaders/signals.py +46 -0
  27. novel_downloader/core/exporters/__init__.py +8 -26
  28. novel_downloader/core/exporters/base.py +107 -31
  29. novel_downloader/core/exporters/common/__init__.py +3 -4
  30. novel_downloader/core/exporters/common/epub.py +92 -171
  31. novel_downloader/core/exporters/common/main_exporter.py +14 -67
  32. novel_downloader/core/exporters/common/txt.py +90 -86
  33. novel_downloader/core/exporters/epub_util.py +184 -1327
  34. novel_downloader/core/exporters/linovelib/__init__.py +3 -2
  35. novel_downloader/core/exporters/linovelib/epub.py +165 -222
  36. novel_downloader/core/exporters/linovelib/main_exporter.py +10 -71
  37. novel_downloader/core/exporters/linovelib/txt.py +76 -66
  38. novel_downloader/core/exporters/qidian.py +15 -11
  39. novel_downloader/core/exporters/registry.py +55 -0
  40. novel_downloader/core/exporters/txt_util.py +67 -0
  41. novel_downloader/core/fetchers/__init__.py +57 -56
  42. novel_downloader/core/fetchers/aaatxt.py +83 -0
  43. novel_downloader/core/fetchers/{biquge/session.py → b520.py} +10 -10
  44. novel_downloader/core/fetchers/{base/session.py → base.py} +63 -47
  45. novel_downloader/core/fetchers/biquyuedu.py +83 -0
  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} +23 -11
  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} +22 -26
  52. novel_downloader/core/fetchers/ixdzs8.py +113 -0
  53. novel_downloader/core/fetchers/jpxs123.py +101 -0
  54. novel_downloader/core/fetchers/{biquge/browser.py → lewenn.py} +15 -15
  55. novel_downloader/core/fetchers/{linovelib/session.py → linovelib.py} +16 -12
  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} +9 -9
  59. novel_downloader/core/fetchers/{qidian/session.py → qidian.py} +55 -40
  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 +60 -0
  63. novel_downloader/core/fetchers/{sfacg/session.py → sfacg.py} +11 -9
  64. novel_downloader/core/fetchers/shencou.py +106 -0
  65. novel_downloader/core/fetchers/{common/browser.py → shuhaige.py} +24 -19
  66. novel_downloader/core/fetchers/tongrenquan.py +84 -0
  67. novel_downloader/core/fetchers/ttkan.py +95 -0
  68. novel_downloader/core/fetchers/{common/session.py → wanbengo.py} +21 -17
  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} +23 -11
  74. novel_downloader/core/fetchers/yibige.py +114 -0
  75. novel_downloader/core/interfaces/__init__.py +8 -14
  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 +4 -17
  79. novel_downloader/core/interfaces/parser.py +5 -6
  80. novel_downloader/core/interfaces/searcher.py +26 -0
  81. novel_downloader/core/parsers/__init__.py +58 -22
  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 +63 -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/main_parser.py → esjzone.py} +67 -67
  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/main_parser.py → linovelib.py} +54 -65
  96. novel_downloader/core/parsers/piaotia.py +189 -0
  97. novel_downloader/core/parsers/qbtr.py +136 -0
  98. novel_downloader/core/parsers/{qianbi/main_parser.py → qianbi.py} +54 -51
  99. novel_downloader/core/parsers/qidian/__init__.py +2 -2
  100. novel_downloader/core/parsers/qidian/book_info_parser.py +58 -59
  101. novel_downloader/core/parsers/qidian/chapter_encrypted.py +290 -346
  102. novel_downloader/core/parsers/qidian/chapter_normal.py +25 -56
  103. novel_downloader/core/parsers/qidian/main_parser.py +19 -57
  104. novel_downloader/core/parsers/qidian/utils/__init__.py +12 -11
  105. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +6 -7
  106. novel_downloader/core/parsers/qidian/utils/fontmap_recover.py +143 -0
  107. novel_downloader/core/parsers/qidian/utils/helpers.py +0 -4
  108. novel_downloader/core/parsers/qidian/utils/node_decryptor.py +2 -2
  109. novel_downloader/core/parsers/quanben5.py +103 -0
  110. novel_downloader/core/parsers/registry.py +57 -0
  111. novel_downloader/core/parsers/{sfacg/main_parser.py → sfacg.py} +46 -48
  112. novel_downloader/core/parsers/shencou.py +215 -0
  113. novel_downloader/core/parsers/shuhaige.py +111 -0
  114. novel_downloader/core/parsers/tongrenquan.py +116 -0
  115. novel_downloader/core/parsers/ttkan.py +132 -0
  116. novel_downloader/core/parsers/wanbengo.py +191 -0
  117. novel_downloader/core/parsers/xiaoshuowu.py +173 -0
  118. novel_downloader/core/parsers/xiguashuwu.py +435 -0
  119. novel_downloader/core/parsers/xs63b.py +161 -0
  120. novel_downloader/core/parsers/xshbook.py +134 -0
  121. novel_downloader/core/parsers/yamibo.py +155 -0
  122. novel_downloader/core/parsers/yibige.py +166 -0
  123. novel_downloader/core/searchers/__init__.py +51 -0
  124. novel_downloader/core/searchers/aaatxt.py +107 -0
  125. novel_downloader/core/searchers/b520.py +84 -0
  126. novel_downloader/core/searchers/base.py +168 -0
  127. novel_downloader/core/searchers/dxmwx.py +105 -0
  128. novel_downloader/core/searchers/eightnovel.py +84 -0
  129. novel_downloader/core/searchers/esjzone.py +102 -0
  130. novel_downloader/core/searchers/hetushu.py +92 -0
  131. novel_downloader/core/searchers/i25zw.py +93 -0
  132. novel_downloader/core/searchers/ixdzs8.py +107 -0
  133. novel_downloader/core/searchers/jpxs123.py +107 -0
  134. novel_downloader/core/searchers/piaotia.py +100 -0
  135. novel_downloader/core/searchers/qbtr.py +106 -0
  136. novel_downloader/core/searchers/qianbi.py +165 -0
  137. novel_downloader/core/searchers/quanben5.py +144 -0
  138. novel_downloader/core/searchers/registry.py +79 -0
  139. novel_downloader/core/searchers/shuhaige.py +124 -0
  140. novel_downloader/core/searchers/tongrenquan.py +110 -0
  141. novel_downloader/core/searchers/ttkan.py +92 -0
  142. novel_downloader/core/searchers/xiaoshuowu.py +122 -0
  143. novel_downloader/core/searchers/xiguashuwu.py +95 -0
  144. novel_downloader/core/searchers/xs63b.py +104 -0
  145. novel_downloader/locales/en.json +36 -79
  146. novel_downloader/locales/zh.json +37 -80
  147. novel_downloader/models/__init__.py +23 -50
  148. novel_downloader/models/book.py +44 -0
  149. novel_downloader/models/config.py +16 -43
  150. novel_downloader/models/login.py +1 -1
  151. novel_downloader/models/search.py +21 -0
  152. novel_downloader/resources/config/settings.toml +39 -74
  153. novel_downloader/resources/css_styles/intro.css +83 -0
  154. novel_downloader/resources/css_styles/main.css +30 -89
  155. novel_downloader/resources/json/xiguashuwu.json +718 -0
  156. novel_downloader/utils/__init__.py +43 -0
  157. novel_downloader/utils/chapter_storage.py +247 -226
  158. novel_downloader/utils/constants.py +5 -50
  159. novel_downloader/utils/cookies.py +6 -18
  160. novel_downloader/utils/crypto_utils/__init__.py +13 -0
  161. novel_downloader/utils/crypto_utils/aes_util.py +90 -0
  162. novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
  163. novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
  164. novel_downloader/utils/{crypto_utils.py → crypto_utils/rc4.py} +3 -10
  165. novel_downloader/utils/epub/__init__.py +34 -0
  166. novel_downloader/utils/epub/builder.py +377 -0
  167. novel_downloader/utils/epub/constants.py +118 -0
  168. novel_downloader/utils/epub/documents.py +297 -0
  169. novel_downloader/utils/epub/models.py +120 -0
  170. novel_downloader/utils/epub/utils.py +179 -0
  171. novel_downloader/utils/file_utils/__init__.py +5 -30
  172. novel_downloader/utils/file_utils/io.py +9 -150
  173. novel_downloader/utils/file_utils/normalize.py +2 -2
  174. novel_downloader/utils/file_utils/sanitize.py +2 -7
  175. novel_downloader/utils/fontocr.py +207 -0
  176. novel_downloader/utils/i18n.py +2 -0
  177. novel_downloader/utils/logger.py +10 -16
  178. novel_downloader/utils/network.py +111 -252
  179. novel_downloader/utils/state.py +5 -90
  180. novel_downloader/utils/text_utils/__init__.py +16 -21
  181. novel_downloader/utils/text_utils/diff_display.py +6 -9
  182. novel_downloader/utils/text_utils/numeric_conversion.py +253 -0
  183. novel_downloader/utils/text_utils/text_cleaner.py +179 -0
  184. novel_downloader/utils/text_utils/truncate_utils.py +62 -0
  185. novel_downloader/utils/time_utils/__init__.py +6 -12
  186. novel_downloader/utils/time_utils/datetime_utils.py +23 -33
  187. novel_downloader/utils/time_utils/sleep_utils.py +5 -10
  188. novel_downloader/web/__init__.py +13 -0
  189. novel_downloader/web/components/__init__.py +11 -0
  190. novel_downloader/web/components/navigation.py +35 -0
  191. novel_downloader/web/main.py +66 -0
  192. novel_downloader/web/pages/__init__.py +17 -0
  193. novel_downloader/web/pages/download.py +78 -0
  194. novel_downloader/web/pages/progress.py +147 -0
  195. novel_downloader/web/pages/search.py +329 -0
  196. novel_downloader/web/services/__init__.py +17 -0
  197. novel_downloader/web/services/client_dialog.py +164 -0
  198. novel_downloader/web/services/cred_broker.py +113 -0
  199. novel_downloader/web/services/cred_models.py +35 -0
  200. novel_downloader/web/services/task_manager.py +264 -0
  201. novel_downloader-2.0.0.dist-info/METADATA +171 -0
  202. novel_downloader-2.0.0.dist-info/RECORD +210 -0
  203. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/entry_points.txt +1 -1
  204. novel_downloader/config/site_rules.py +0 -94
  205. novel_downloader/core/downloaders/biquge.py +0 -25
  206. novel_downloader/core/downloaders/esjzone.py +0 -25
  207. novel_downloader/core/downloaders/linovelib.py +0 -25
  208. novel_downloader/core/downloaders/sfacg.py +0 -25
  209. novel_downloader/core/downloaders/yamibo.py +0 -25
  210. novel_downloader/core/exporters/biquge.py +0 -25
  211. novel_downloader/core/exporters/esjzone.py +0 -25
  212. novel_downloader/core/exporters/qianbi.py +0 -25
  213. novel_downloader/core/exporters/sfacg.py +0 -25
  214. novel_downloader/core/exporters/yamibo.py +0 -25
  215. novel_downloader/core/factory/__init__.py +0 -20
  216. novel_downloader/core/factory/downloader.py +0 -73
  217. novel_downloader/core/factory/exporter.py +0 -58
  218. novel_downloader/core/factory/fetcher.py +0 -96
  219. novel_downloader/core/factory/parser.py +0 -86
  220. novel_downloader/core/fetchers/base/__init__.py +0 -14
  221. novel_downloader/core/fetchers/base/browser.py +0 -403
  222. novel_downloader/core/fetchers/biquge/__init__.py +0 -14
  223. novel_downloader/core/fetchers/common/__init__.py +0 -14
  224. novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
  225. novel_downloader/core/fetchers/esjzone/browser.py +0 -204
  226. novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
  227. novel_downloader/core/fetchers/linovelib/browser.py +0 -193
  228. novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
  229. novel_downloader/core/fetchers/qidian/__init__.py +0 -14
  230. novel_downloader/core/fetchers/qidian/browser.py +0 -318
  231. novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
  232. novel_downloader/core/fetchers/sfacg/browser.py +0 -189
  233. novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
  234. novel_downloader/core/fetchers/yamibo/browser.py +0 -229
  235. novel_downloader/core/parsers/biquge/__init__.py +0 -10
  236. novel_downloader/core/parsers/biquge/main_parser.py +0 -134
  237. novel_downloader/core/parsers/common/__init__.py +0 -13
  238. novel_downloader/core/parsers/common/helper.py +0 -323
  239. novel_downloader/core/parsers/common/main_parser.py +0 -106
  240. novel_downloader/core/parsers/esjzone/__init__.py +0 -10
  241. novel_downloader/core/parsers/linovelib/__init__.py +0 -10
  242. novel_downloader/core/parsers/qianbi/__init__.py +0 -10
  243. novel_downloader/core/parsers/sfacg/__init__.py +0 -10
  244. novel_downloader/core/parsers/yamibo/__init__.py +0 -10
  245. novel_downloader/core/parsers/yamibo/main_parser.py +0 -194
  246. novel_downloader/models/browser.py +0 -21
  247. novel_downloader/models/chapter.py +0 -25
  248. novel_downloader/models/site_rules.py +0 -99
  249. novel_downloader/models/tasks.py +0 -33
  250. novel_downloader/models/types.py +0 -15
  251. novel_downloader/resources/css_styles/volume-intro.css +0 -56
  252. novel_downloader/resources/json/replace_word_map.json +0 -4
  253. novel_downloader/resources/text/blacklist.txt +0 -22
  254. novel_downloader/tui/__init__.py +0 -7
  255. novel_downloader/tui/app.py +0 -32
  256. novel_downloader/tui/main.py +0 -17
  257. novel_downloader/tui/screens/__init__.py +0 -14
  258. novel_downloader/tui/screens/home.py +0 -198
  259. novel_downloader/tui/screens/login.py +0 -74
  260. novel_downloader/tui/styles/home_layout.tcss +0 -79
  261. novel_downloader/tui/widgets/richlog_handler.py +0 -24
  262. novel_downloader/utils/cache.py +0 -24
  263. novel_downloader/utils/fontocr/__init__.py +0 -22
  264. novel_downloader/utils/fontocr/model_loader.py +0 -69
  265. novel_downloader/utils/fontocr/ocr_v1.py +0 -303
  266. novel_downloader/utils/fontocr/ocr_v2.py +0 -752
  267. novel_downloader/utils/hash_store.py +0 -279
  268. novel_downloader/utils/hash_utils.py +0 -103
  269. novel_downloader/utils/text_utils/chapter_formatting.py +0 -46
  270. novel_downloader/utils/text_utils/font_mapping.py +0 -28
  271. novel_downloader/utils/text_utils/text_cleaning.py +0 -107
  272. novel_downloader-1.4.5.dist-info/METADATA +0 -196
  273. novel_downloader-1.4.5.dist-info/RECORD +0 -165
  274. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/WHEEL +0 -0
  275. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/licenses/LICENSE +0 -0
  276. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/top_level.txt +0 -0
@@ -8,41 +8,35 @@ Contains the logic for exporting novel content as a single `.epub` file.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- import html
12
- import json
13
- import re
14
11
  from pathlib import Path
15
12
  from typing import TYPE_CHECKING
16
13
 
17
14
  from novel_downloader.core.exporters.epub_util import (
18
- Book,
15
+ build_epub_chapter,
16
+ download_cover,
17
+ finalize_export,
18
+ inline_remote_images,
19
+ prepare_builder,
20
+ remove_all_images,
21
+ )
22
+ from novel_downloader.utils import (
23
+ download,
24
+ get_cleaner,
25
+ )
26
+ from novel_downloader.utils.constants import DEFAULT_IMAGE_SUFFIX
27
+ from novel_downloader.utils.epub import (
19
28
  Chapter,
20
- StyleSheet,
21
29
  Volume,
22
30
  )
23
- from novel_downloader.utils.constants import CSS_MAIN_PATH
24
- from novel_downloader.utils.file_utils import sanitize_filename
25
- from novel_downloader.utils.network import download_image
26
- from novel_downloader.utils.text_utils import clean_chapter_title
27
31
 
28
32
  if TYPE_CHECKING:
29
33
  from .main_exporter import CommonExporter
30
34
 
31
- _IMAGE_WRAPPER = (
32
- '<div class="duokan-image-single illus"><img src="../Images/{filename}" /></div>'
33
- )
34
- _IMG_TAG_PATTERN = re.compile(
35
- r'<img\s+[^>]*src=[\'"]([^\'"]+)[\'"][^>]*>', re.IGNORECASE
36
- )
37
- _RAW_HTML_RE = re.compile(
38
- r'^(<img\b[^>]*?\/>|<div class="duokan-image-single illus">.*?<\/div>)$', re.DOTALL
39
- )
40
-
41
35
 
42
36
  def common_export_as_epub(
43
37
  exporter: CommonExporter,
44
38
  book_id: str,
45
- ) -> None:
39
+ ) -> Path | None:
46
40
  """
47
41
  Export a single novel (identified by `book_id`) to an EPUB file.
48
42
 
@@ -50,132 +44,142 @@ def common_export_as_epub(
50
44
  1. Load `book_info.json` for metadata.
51
45
  2. Generate introductory HTML and optionally include the cover image.
52
46
  3. Initialize the EPUB container.
53
- 4. Iterate through volumes and chapters, convert each to XHTML.
47
+ 4. Iterate through volumes and chapters in volume-batches, convert each to XHTML.
54
48
  5. Assemble the spine, TOC, CSS and write out the final `.epub`.
55
49
 
56
- :param saver: The saver instance, carrying config and path info.
50
+ :param exporter: The exporter instance, carrying config and path info.
57
51
  :param book_id: Identifier of the novel (used as subdirectory name).
58
52
  """
59
53
  TAG = "[exporter]"
60
54
  config = exporter._config
61
- # --- Paths & options ---
55
+
62
56
  raw_base = exporter._raw_data_dir / book_id
63
- img_dir = exporter._cache_dir / book_id / "images"
57
+ img_dir = raw_base / "images"
64
58
  out_dir = exporter.output_dir
59
+
65
60
  img_dir.mkdir(parents=True, exist_ok=True)
66
61
  out_dir.mkdir(parents=True, exist_ok=True)
67
62
 
63
+ cleaner = get_cleaner(
64
+ enabled=config.clean_text,
65
+ config=config.cleaner_cfg,
66
+ )
67
+
68
68
  # --- Load book_info.json ---
69
- info_path = raw_base / "book_info.json"
70
- try:
71
- info_text = info_path.read_text(encoding="utf-8")
72
- book_info = json.loads(info_text)
73
- except Exception as e:
74
- exporter.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
75
- return
69
+ book_info = exporter._load_book_info(book_id)
70
+ if not book_info:
71
+ return None
76
72
 
77
73
  book_name = book_info.get("book_name", book_id)
78
74
  book_author = book_info.get("author", "")
75
+
79
76
  exporter.logger.info(
80
77
  "%s Starting EPUB generation: %s (ID: %s)", TAG, book_name, book_id
81
78
  )
82
79
 
83
- # --- Generate intro + cover ---
84
- cover_path: Path | None = None
85
- cover_url = book_info.get("cover_url", "")
86
- if config.include_cover and cover_url:
87
- cover_path = download_image(
88
- cover_url,
89
- raw_base,
90
- target_name="cover",
91
- on_exist="overwrite",
92
- )
93
- if not cover_path:
94
- exporter.logger.warning("Failed to download cover from %s", cover_url)
80
+ cover_path = download_cover(
81
+ book_info.get("cover_url", ""),
82
+ raw_base,
83
+ config.include_cover,
84
+ exporter.logger,
85
+ TAG,
86
+ )
95
87
 
96
88
  # --- Initialize EPUB ---
97
- book = Book(
89
+ book, main_css = prepare_builder(
90
+ site_name=exporter.site,
91
+ book_id=book_id,
98
92
  title=book_name,
99
93
  author=book_author,
100
94
  description=book_info.get("summary", ""),
101
- cover_path=cover_path,
102
- subject=book_info.get("subject", []),
95
+ subject=book_info.get("tags", []),
103
96
  serial_status=book_info.get("serial_status", ""),
104
97
  word_count=book_info.get("word_count", ""),
105
- uid=f"{exporter.site}_{book_id}",
106
- )
107
- main_css = StyleSheet(
108
- id="main_style",
109
- content=CSS_MAIN_PATH.read_text(encoding="utf-8"),
110
- filename="main.css",
98
+ cover_path=cover_path,
111
99
  )
112
- book.add_stylesheet(main_css)
113
100
 
114
101
  # --- Compile chapters ---
115
102
  volumes = book_info.get("volumes", [])
116
- for vol_index, vol in enumerate(volumes, start=1):
117
- raw_vol_name = vol.get("volume_name", "")
118
- raw_vol_name = raw_vol_name.replace(book_name, "").strip()
119
- vol_name = raw_vol_name or f"Volume {vol_index}"
120
- exporter.logger.info("Processing volume %d: %s", vol_index, vol_name)
103
+ if not volumes:
104
+ exporter.logger.warning("%s No volumes found in metadata.", TAG)
121
105
 
122
- vol_cover_path: Path | None = None
106
+ for vol_index, vol in enumerate(volumes, start=1):
107
+ raw_name = vol.get("volume_name", "")
108
+ raw_name = cleaner.clean_title(raw_name.replace(book_name, ""))
109
+ vol_name = raw_name or f"Volume {vol_index}"
110
+ exporter.logger.info("%s Processing volume %d: %s", TAG, vol_index, vol_name)
111
+
112
+ # Batch-fetch chapters for this volume
113
+ chap_ids = [
114
+ chap["chapterId"]
115
+ for chap in vol.get("chapters", [])
116
+ if chap.get("chapterId")
117
+ ]
118
+ chap_map = exporter._get_chapters(book_id, chap_ids)
119
+
120
+ vol_cover: Path | None = None
123
121
  vol_cover_url = vol.get("volume_cover", "")
124
122
  if vol_cover_url:
125
- vol_cover_path = download_image(
123
+ vol_cover = download(
126
124
  vol_cover_url,
127
125
  img_dir,
128
126
  on_exist="skip",
127
+ default_suffix=DEFAULT_IMAGE_SUFFIX,
129
128
  )
130
129
 
131
130
  curr_vol = Volume(
132
131
  id=f"vol_{vol_index}",
133
132
  title=vol_name,
134
- intro=vol.get("volume_intro", ""),
135
- cover=vol_cover_path,
133
+ intro=cleaner.clean_content(vol.get("volume_intro", "")),
134
+ cover=vol_cover,
136
135
  )
137
136
 
138
- for chap in vol.get("chapters", []):
139
- chap_id = chap.get("chapterId")
140
- chap_title = chap.get("title", "")
137
+ for chap_meta in vol.get("chapters", []):
138
+ chap_id = chap_meta.get("chapterId")
141
139
  if not chap_id:
142
140
  exporter.logger.warning(
143
141
  "%s Missing chapterId, skipping: %s",
144
142
  TAG,
145
- chap,
143
+ chap_meta,
146
144
  )
147
145
  continue
148
146
 
149
- chapter_data = exporter._get_chapter(book_id, chap_id)
150
- if not chapter_data:
147
+ chap_title = chap_meta.get("title", "")
148
+ data = chap_map.get(chap_id)
149
+ if not data:
151
150
  exporter.logger.info(
152
- "%s Missing chapter file: %s (%s), skipping.",
151
+ "%s Missing chapter: %s (%s), skipping.",
153
152
  TAG,
154
153
  chap_title,
155
154
  chap_id,
156
155
  )
157
156
  continue
158
157
 
159
- title = clean_chapter_title(chapter_data.get("title", "")) or chap_id
160
- content: str = chapter_data.get("content", "")
161
- content, img_paths = _inline_remote_images(content, img_dir)
162
- chap_html = _txt_to_html(
163
- chapter_title=title,
164
- chapter_text=content,
165
- extras={
166
- "作者说": chapter_data.get("author_say", ""),
167
- },
158
+ title = cleaner.clean_title(data.get("title", chap_title)) or chap_id
159
+ content = cleaner.clean_content(data.get("content", ""))
160
+ extra = data.get("extra", {})
161
+ author_note = cleaner.clean_content(extra.get("author_say", ""))
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 {}
168
+
169
+ chap_html = build_epub_chapter(
170
+ title=title,
171
+ paragraphs=content,
172
+ extras=extras,
168
173
  )
169
- curr_vol.add_chapter(
174
+ curr_vol.chapters.append(
170
175
  Chapter(
171
176
  id=f"c_{chap_id}",
177
+ filename=f"c{chap_id}.xhtml",
172
178
  title=title,
173
179
  content=chap_html,
174
180
  css=[main_css],
175
181
  )
176
182
  )
177
- for img_path in img_paths:
178
- book.add_image(img_path)
179
183
 
180
184
  book.add_volume(curr_vol)
181
185
 
@@ -185,93 +189,10 @@ def common_export_as_epub(
185
189
  author=book_info.get("author"),
186
190
  ext="epub",
187
191
  )
188
- out_path = out_dir / sanitize_filename(out_name)
189
-
190
- try:
191
- book.export(out_path)
192
- exporter.logger.info("%s EPUB successfully written to %s", TAG, out_path)
193
- except Exception as e:
194
- exporter.logger.error("%s Failed to write EPUB to %s: %s", TAG, out_path, e)
195
- return
196
-
197
-
198
- def _inline_remote_images(
199
- content: str,
200
- image_dir: str | Path,
201
- ) -> tuple[str, list[Path]]:
202
- """
203
- Download every remote `<img src="...">` in `content` into `image_dir`,
204
- and replace the original tag with _IMAGE_WRAPPER
205
- pointing to the local filename.
206
-
207
- :param content: HTML/text of the chapter containing <img> tags.
208
- :param image_dir: Directory to save downloaded images into.
209
- :return: A tuple (modified_content, list_of_downloaded_image_paths).
210
- """
211
- downloaded_images: list[Path] = []
212
-
213
- def _replace(match: re.Match[str]) -> str:
214
- url = match.group(1)
215
- try:
216
- # download_image returns a Path or None
217
- local_path = download_image(
218
- url,
219
- image_dir,
220
- target_name=None,
221
- on_exist="skip",
222
- )
223
- if not local_path:
224
- return match.group(0)
225
-
226
- downloaded_images.append(local_path)
227
- return _IMAGE_WRAPPER.format(filename=local_path.name)
228
- except Exception:
229
- return match.group(0)
230
-
231
- modified_content = _IMG_TAG_PATTERN.sub(_replace, content)
232
- return modified_content, downloaded_images
233
-
234
-
235
- def _txt_to_html(
236
- chapter_title: str,
237
- chapter_text: str,
238
- extras: dict[str, str] | None = None,
239
- ) -> str:
240
- """
241
- Convert chapter text and author note to styled HTML.
242
-
243
- :param chapter_title: Title of the chapter.
244
- :param chapter_text: Main content of the chapter.
245
- :param extras: Optional dict of titles and content, e.g. {"作者说": "text"}.
246
- :return: Rendered HTML as a string.
247
- """
248
-
249
- def _render_block(text: str) -> str:
250
- lines = (line.strip() for line in text.splitlines() if line.strip())
251
- out = []
252
- for line in lines:
253
- # preserve raw HTML, otherwise wrap in <p>
254
- if _RAW_HTML_RE.match(line):
255
- out.append(line)
256
- else:
257
- out.append(f"<p>{html.escape(line)}</p>")
258
- return "\n".join(out)
259
-
260
- parts = []
261
- parts.append(f"<h2>{html.escape(chapter_title)}</h2>")
262
- parts.append(_render_block(chapter_text))
263
-
264
- if extras:
265
- for title, note in extras.items():
266
- note = note.strip()
267
- if not note:
268
- continue
269
- parts.extend(
270
- [
271
- "<hr />",
272
- f"<p>{html.escape(title)}</p>",
273
- _render_block(note),
274
- ]
275
- )
276
-
277
- return "\n".join(parts)
192
+ return finalize_export(
193
+ book=book,
194
+ out_dir=out_dir,
195
+ filename=out_name,
196
+ logger=exporter.logger,
197
+ tag=TAG,
198
+ )
@@ -3,40 +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
 
11
- from collections.abc import Mapping
12
- from typing import Any
9
+ from pathlib import Path
13
10
 
14
11
  from novel_downloader.core.exporters.base import BaseExporter
15
- from novel_downloader.models import ExporterConfig
16
- from novel_downloader.utils.chapter_storage import ChapterStorage
17
12
 
13
+ from .epub import common_export_as_epub
18
14
  from .txt import common_export_as_txt
19
15
 
20
16
 
21
17
  class CommonExporter(BaseExporter):
22
18
  """
23
19
  CommonExporter is a exporter that processes and exports novels.
20
+
24
21
  It extends the BaseExporter interface and provides
25
22
  logic for exporting full novels as plain text (.txt) files
26
23
  and EPUB (.epub) files.
27
24
  """
28
25
 
29
- def __init__(
30
- self,
31
- config: ExporterConfig,
32
- site: str,
33
- chap_folders: list[str] | None = None,
34
- ):
35
- super().__init__(config, site)
36
- self._chapter_storage_cache: dict[str, list[ChapterStorage]] = {}
37
- self._chap_folders: list[str] = chap_folders or ["chapters"]
38
-
39
- def export_as_txt(self, book_id: str) -> None:
26
+ def export_as_txt(self, book_id: str) -> Path | None:
40
27
  """
41
28
  Compile and export a complete novel as a single .txt file.
42
29
 
@@ -51,67 +38,27 @@ class CommonExporter(BaseExporter):
51
38
 
52
39
  :param book_id: The book identifier (used to locate raw data)
53
40
  """
41
+ book_id = self._normalize_book_id(book_id)
54
42
  self._init_chapter_storages(book_id)
55
43
  return common_export_as_txt(self, book_id)
56
44
 
57
- def export_as_epub(self, book_id: str) -> None:
45
+ def export_as_epub(self, book_id: str) -> Path | None:
58
46
  """
59
47
  Persist the assembled book as a EPUB (.epub) file.
60
48
 
61
49
  :param book_id: The book identifier.
62
50
  :raises NotImplementedError: If the method is not overridden.
63
51
  """
64
- try:
65
- from .epub import common_export_as_epub
66
- except ImportError as err:
67
- raise NotImplementedError(
68
- "EPUB export not supported. Please install 'ebooklib'"
69
- ) from err
70
-
52
+ book_id = self._normalize_book_id(book_id)
71
53
  self._init_chapter_storages(book_id)
72
54
  return common_export_as_epub(self, book_id)
73
55
 
74
- @property
75
- def site(self) -> str:
76
- """
77
- Get the site identifier.
78
-
79
- :return: The site string.
56
+ @staticmethod
57
+ def _normalize_book_id(book_id: str) -> str:
80
58
  """
81
- return self._site
82
-
83
- def _get_chapter(
84
- self,
85
- book_id: str,
86
- chap_id: str,
87
- ) -> Mapping[str, Any]:
88
- for storage in self._chapter_storage_cache[book_id]:
89
- data = storage.get(chap_id)
90
- if data:
91
- return data
92
- return {}
59
+ Normalize a book identifier.
93
60
 
94
- def _init_chapter_storages(self, book_id: str) -> None:
95
- if book_id in self._chapter_storage_cache:
96
- return
97
- raw_base = self._raw_data_dir / book_id
98
- self._chapter_storage_cache[book_id] = [
99
- ChapterStorage(
100
- raw_base=raw_base,
101
- namespace=ns,
102
- backend_type=self._config.storage_backend,
103
- )
104
- for ns in self._chap_folders
105
- ]
106
-
107
- def _on_close(self) -> None:
108
- """
109
- Close all ChapterStorage connections in the cache.
61
+ Subclasses may override this method to transform the book ID
62
+ into their preferred format.
110
63
  """
111
- for storages in self._chapter_storage_cache.values():
112
- for storage in storages:
113
- try:
114
- storage.close()
115
- except Exception as e:
116
- self.logger.warning("Failed to close storage %s: %s", storage, e)
117
- self._chapter_storage_cache.clear()
64
+ return book_id.replace("/", "-")