novel-downloader 1.3.2__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/clean.py +97 -78
  3. novel_downloader/cli/config.py +177 -0
  4. novel_downloader/cli/download.py +132 -87
  5. novel_downloader/cli/export.py +77 -0
  6. novel_downloader/cli/main.py +21 -28
  7. novel_downloader/config/__init__.py +1 -25
  8. novel_downloader/config/adapter.py +32 -31
  9. novel_downloader/config/loader.py +3 -3
  10. novel_downloader/config/site_rules.py +1 -2
  11. novel_downloader/core/__init__.py +3 -6
  12. novel_downloader/core/downloaders/__init__.py +10 -13
  13. novel_downloader/core/downloaders/base.py +233 -0
  14. novel_downloader/core/downloaders/biquge.py +27 -0
  15. novel_downloader/core/downloaders/common.py +414 -0
  16. novel_downloader/core/downloaders/esjzone.py +27 -0
  17. novel_downloader/core/downloaders/linovelib.py +27 -0
  18. novel_downloader/core/downloaders/qianbi.py +27 -0
  19. novel_downloader/core/downloaders/qidian.py +352 -0
  20. novel_downloader/core/downloaders/sfacg.py +27 -0
  21. novel_downloader/core/downloaders/yamibo.py +27 -0
  22. novel_downloader/core/exporters/__init__.py +37 -0
  23. novel_downloader/core/{savers → exporters}/base.py +73 -44
  24. novel_downloader/core/exporters/biquge.py +25 -0
  25. novel_downloader/core/exporters/common/__init__.py +12 -0
  26. novel_downloader/core/{savers → exporters}/common/epub.py +40 -52
  27. novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +36 -39
  28. novel_downloader/core/{savers → exporters}/common/txt.py +20 -24
  29. novel_downloader/core/exporters/epub_utils/__init__.py +40 -0
  30. novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -1
  31. novel_downloader/core/exporters/epub_utils/image_loader.py +131 -0
  32. novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -3
  33. novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +49 -2
  34. novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -1
  35. novel_downloader/core/exporters/esjzone.py +25 -0
  36. novel_downloader/core/exporters/linovelib/__init__.py +10 -0
  37. novel_downloader/core/exporters/linovelib/epub.py +449 -0
  38. novel_downloader/core/exporters/linovelib/main_exporter.py +127 -0
  39. novel_downloader/core/exporters/linovelib/txt.py +129 -0
  40. novel_downloader/core/exporters/qianbi.py +25 -0
  41. novel_downloader/core/{savers → exporters}/qidian.py +8 -8
  42. novel_downloader/core/exporters/sfacg.py +25 -0
  43. novel_downloader/core/exporters/yamibo.py +25 -0
  44. novel_downloader/core/factory/__init__.py +5 -17
  45. novel_downloader/core/factory/downloader.py +24 -126
  46. novel_downloader/core/factory/exporter.py +58 -0
  47. novel_downloader/core/factory/fetcher.py +96 -0
  48. novel_downloader/core/factory/parser.py +17 -12
  49. novel_downloader/core/{requesters → fetchers}/__init__.py +22 -15
  50. novel_downloader/core/{requesters → fetchers}/base/__init__.py +2 -4
  51. novel_downloader/core/fetchers/base/browser.py +383 -0
  52. novel_downloader/core/fetchers/base/rate_limiter.py +86 -0
  53. novel_downloader/core/fetchers/base/session.py +419 -0
  54. novel_downloader/core/fetchers/biquge/__init__.py +14 -0
  55. novel_downloader/core/{requesters/biquge/async_session.py → fetchers/biquge/browser.py} +18 -6
  56. novel_downloader/core/{requesters → fetchers}/biquge/session.py +23 -30
  57. novel_downloader/core/fetchers/common/__init__.py +14 -0
  58. novel_downloader/core/fetchers/common/browser.py +79 -0
  59. novel_downloader/core/{requesters/common/async_session.py → fetchers/common/session.py} +8 -25
  60. novel_downloader/core/fetchers/esjzone/__init__.py +14 -0
  61. novel_downloader/core/fetchers/esjzone/browser.py +202 -0
  62. novel_downloader/core/{requesters/esjzone/async_session.py → fetchers/esjzone/session.py} +62 -42
  63. novel_downloader/core/fetchers/linovelib/__init__.py +14 -0
  64. novel_downloader/core/fetchers/linovelib/browser.py +178 -0
  65. novel_downloader/core/fetchers/linovelib/session.py +178 -0
  66. novel_downloader/core/fetchers/qianbi/__init__.py +14 -0
  67. novel_downloader/core/{requesters/qianbi/session.py → fetchers/qianbi/browser.py} +30 -48
  68. novel_downloader/core/{requesters/qianbi/async_session.py → fetchers/qianbi/session.py} +18 -6
  69. novel_downloader/core/fetchers/qidian/__init__.py +14 -0
  70. novel_downloader/core/fetchers/qidian/browser.py +266 -0
  71. novel_downloader/core/fetchers/qidian/session.py +326 -0
  72. novel_downloader/core/fetchers/sfacg/__init__.py +14 -0
  73. novel_downloader/core/fetchers/sfacg/browser.py +189 -0
  74. novel_downloader/core/{requesters/sfacg/async_session.py → fetchers/sfacg/session.py} +43 -73
  75. novel_downloader/core/fetchers/yamibo/__init__.py +14 -0
  76. novel_downloader/core/fetchers/yamibo/browser.py +229 -0
  77. novel_downloader/core/{requesters/yamibo/async_session.py → fetchers/yamibo/session.py} +62 -44
  78. novel_downloader/core/interfaces/__init__.py +8 -12
  79. novel_downloader/core/interfaces/downloader.py +54 -0
  80. novel_downloader/core/interfaces/{saver.py → exporter.py} +12 -12
  81. novel_downloader/core/interfaces/fetcher.py +162 -0
  82. novel_downloader/core/interfaces/parser.py +6 -7
  83. novel_downloader/core/parsers/__init__.py +5 -6
  84. novel_downloader/core/parsers/base.py +9 -13
  85. novel_downloader/core/parsers/biquge/main_parser.py +12 -13
  86. novel_downloader/core/parsers/common/helper.py +3 -3
  87. novel_downloader/core/parsers/common/main_parser.py +39 -34
  88. novel_downloader/core/parsers/esjzone/main_parser.py +24 -17
  89. novel_downloader/core/parsers/linovelib/__init__.py +10 -0
  90. novel_downloader/core/parsers/linovelib/main_parser.py +210 -0
  91. novel_downloader/core/parsers/qianbi/main_parser.py +21 -15
  92. novel_downloader/core/parsers/qidian/__init__.py +2 -11
  93. novel_downloader/core/parsers/qidian/book_info_parser.py +113 -0
  94. novel_downloader/core/parsers/qidian/{browser/chapter_encrypted.py → chapter_encrypted.py} +162 -135
  95. novel_downloader/core/parsers/qidian/chapter_normal.py +150 -0
  96. novel_downloader/core/parsers/qidian/{session/chapter_router.py → chapter_router.py} +15 -15
  97. novel_downloader/core/parsers/qidian/{browser/main_parser.py → main_parser.py} +49 -40
  98. novel_downloader/core/parsers/qidian/utils/__init__.py +27 -0
  99. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +145 -0
  100. novel_downloader/core/parsers/qidian/{shared → utils}/helpers.py +41 -68
  101. novel_downloader/core/parsers/qidian/{session → utils}/node_decryptor.py +64 -50
  102. novel_downloader/core/parsers/sfacg/main_parser.py +12 -12
  103. novel_downloader/core/parsers/yamibo/main_parser.py +10 -10
  104. novel_downloader/locales/en.json +18 -2
  105. novel_downloader/locales/zh.json +18 -2
  106. novel_downloader/models/__init__.py +64 -0
  107. novel_downloader/models/browser.py +21 -0
  108. novel_downloader/models/chapter.py +25 -0
  109. novel_downloader/models/config.py +100 -0
  110. novel_downloader/models/login.py +20 -0
  111. novel_downloader/models/site_rules.py +99 -0
  112. novel_downloader/models/tasks.py +33 -0
  113. novel_downloader/models/types.py +15 -0
  114. novel_downloader/resources/config/settings.toml +31 -25
  115. novel_downloader/resources/json/linovelib_font_map.json +3573 -0
  116. novel_downloader/tui/__init__.py +7 -0
  117. novel_downloader/tui/app.py +32 -0
  118. novel_downloader/tui/main.py +17 -0
  119. novel_downloader/tui/screens/__init__.py +14 -0
  120. novel_downloader/tui/screens/home.py +191 -0
  121. novel_downloader/tui/screens/login.py +74 -0
  122. novel_downloader/tui/styles/home_layout.tcss +79 -0
  123. novel_downloader/tui/widgets/richlog_handler.py +24 -0
  124. novel_downloader/utils/__init__.py +6 -0
  125. novel_downloader/utils/chapter_storage.py +25 -38
  126. novel_downloader/utils/constants.py +15 -5
  127. novel_downloader/utils/cookies.py +66 -0
  128. novel_downloader/utils/crypto_utils.py +1 -74
  129. novel_downloader/utils/file_utils/io.py +1 -1
  130. novel_downloader/utils/fontocr/ocr_v1.py +2 -1
  131. novel_downloader/utils/fontocr/ocr_v2.py +2 -2
  132. novel_downloader/utils/hash_store.py +10 -18
  133. novel_downloader/utils/hash_utils.py +3 -2
  134. novel_downloader/utils/logger.py +2 -3
  135. novel_downloader/utils/network.py +53 -39
  136. novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
  137. novel_downloader/utils/text_utils/font_mapping.py +1 -1
  138. novel_downloader/utils/text_utils/text_cleaning.py +1 -1
  139. novel_downloader/utils/time_utils/datetime_utils.py +3 -3
  140. novel_downloader/utils/time_utils/sleep_utils.py +3 -3
  141. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/METADATA +72 -38
  142. novel_downloader-1.4.0.dist-info/RECORD +170 -0
  143. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
  144. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/entry_points.txt +1 -0
  145. novel_downloader/cli/interactive.py +0 -66
  146. novel_downloader/cli/settings.py +0 -177
  147. novel_downloader/config/models.py +0 -187
  148. novel_downloader/core/downloaders/base/__init__.py +0 -14
  149. novel_downloader/core/downloaders/base/base_async.py +0 -153
  150. novel_downloader/core/downloaders/base/base_sync.py +0 -208
  151. novel_downloader/core/downloaders/biquge/__init__.py +0 -14
  152. novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
  153. novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
  154. novel_downloader/core/downloaders/common/__init__.py +0 -14
  155. novel_downloader/core/downloaders/common/common_async.py +0 -218
  156. novel_downloader/core/downloaders/common/common_sync.py +0 -210
  157. novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
  158. novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
  159. novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
  160. novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
  161. novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
  162. novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
  163. novel_downloader/core/downloaders/qidian/__init__.py +0 -10
  164. novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -227
  165. novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
  166. novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
  167. novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
  168. novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
  169. novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
  170. novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
  171. novel_downloader/core/factory/requester.py +0 -144
  172. novel_downloader/core/factory/saver.py +0 -56
  173. novel_downloader/core/interfaces/async_downloader.py +0 -36
  174. novel_downloader/core/interfaces/async_requester.py +0 -84
  175. novel_downloader/core/interfaces/sync_downloader.py +0 -36
  176. novel_downloader/core/interfaces/sync_requester.py +0 -82
  177. novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
  178. novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
  179. novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
  180. novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
  181. novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
  182. novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
  183. novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
  184. novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
  185. novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
  186. novel_downloader/core/requesters/base/async_session.py +0 -410
  187. novel_downloader/core/requesters/base/browser.py +0 -337
  188. novel_downloader/core/requesters/base/session.py +0 -378
  189. novel_downloader/core/requesters/biquge/__init__.py +0 -14
  190. novel_downloader/core/requesters/common/__init__.py +0 -17
  191. novel_downloader/core/requesters/common/session.py +0 -113
  192. novel_downloader/core/requesters/esjzone/__init__.py +0 -13
  193. novel_downloader/core/requesters/esjzone/session.py +0 -235
  194. novel_downloader/core/requesters/qianbi/__init__.py +0 -13
  195. novel_downloader/core/requesters/qidian/__init__.py +0 -21
  196. novel_downloader/core/requesters/qidian/broswer.py +0 -307
  197. novel_downloader/core/requesters/qidian/session.py +0 -290
  198. novel_downloader/core/requesters/sfacg/__init__.py +0 -13
  199. novel_downloader/core/requesters/sfacg/session.py +0 -242
  200. novel_downloader/core/requesters/yamibo/__init__.py +0 -13
  201. novel_downloader/core/requesters/yamibo/session.py +0 -237
  202. novel_downloader/core/savers/__init__.py +0 -34
  203. novel_downloader/core/savers/biquge.py +0 -25
  204. novel_downloader/core/savers/common/__init__.py +0 -12
  205. novel_downloader/core/savers/epub_utils/__init__.py +0 -26
  206. novel_downloader/core/savers/esjzone.py +0 -25
  207. novel_downloader/core/savers/qianbi.py +0 -25
  208. novel_downloader/core/savers/sfacg.py +0 -25
  209. novel_downloader/core/savers/yamibo.py +0 -25
  210. novel_downloader/resources/config/rules.toml +0 -196
  211. novel_downloader-1.3.2.dist-info/RECORD +0 -165
  212. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/licenses/LICENSE +0 -0
  213. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.exporters.common
4
+ --------------------------------------
5
+
6
+ This module provides the `CommonExporter` class for
7
+ handling the saving process of novels.
8
+ """
9
+
10
+ from .main_exporter import CommonExporter
11
+
12
+ __all__ = ["CommonExporter"]
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.savers.common.epub
4
- ----------------------------------------
3
+ novel_downloader.core.exporters.common.epub
4
+ -------------------------------------------
5
5
 
6
6
  Contains the logic for exporting novel content as a single `.epub` file.
7
7
  """
@@ -11,55 +11,32 @@ from __future__ import annotations
11
11
  import json
12
12
  from pathlib import Path
13
13
  from typing import TYPE_CHECKING
14
- from urllib.parse import unquote, urlparse
15
14
 
16
15
  from ebooklib import epub
17
16
 
18
- from novel_downloader.core.savers.epub_utils import (
17
+ from novel_downloader.core.exporters.epub_utils import (
18
+ add_images_from_dir,
19
19
  chapter_txt_to_html,
20
20
  create_css_items,
21
21
  create_volume_intro,
22
22
  generate_book_intro_html,
23
23
  init_epub,
24
+ inline_remote_images,
24
25
  )
25
26
  from novel_downloader.utils.constants import (
26
- DEFAULT_IMAGE_SUFFIX,
27
27
  EPUB_OPTIONS,
28
28
  EPUB_TEXT_FOLDER,
29
29
  )
30
30
  from novel_downloader.utils.file_utils import sanitize_filename
31
+ from novel_downloader.utils.network import download_image
31
32
  from novel_downloader.utils.text_utils import clean_chapter_title
32
33
 
33
34
  if TYPE_CHECKING:
34
- from .main_saver import CommonSaver
35
+ from .main_exporter import CommonExporter
35
36
 
36
37
 
37
- def _image_url_to_filename(url: str) -> str:
38
- """
39
- Parse and sanitize a image filename from a URL.
40
- If no filename or suffix exists, fallback to default name and extension.
41
-
42
- :param url: URL string
43
- :return: Safe filename string
44
- """
45
- if not url:
46
- return ""
47
-
48
- parsed_url = urlparse(url)
49
- path = unquote(parsed_url.path)
50
- filename = Path(path).name
51
-
52
- if not filename:
53
- filename = "image"
54
-
55
- if not Path(filename).suffix:
56
- filename += DEFAULT_IMAGE_SUFFIX
57
-
58
- return filename
59
-
60
-
61
- def common_save_as_epub(
62
- saver: CommonSaver,
38
+ def common_export_as_epub(
39
+ exporter: CommonExporter,
63
40
  book_id: str,
64
41
  ) -> None:
65
42
  """
@@ -75,12 +52,13 @@ def common_save_as_epub(
75
52
  :param saver: The saver instance, carrying config and path info.
76
53
  :param book_id: Identifier of the novel (used as subdirectory name).
77
54
  """
78
- TAG = "[saver]"
79
- site = saver.site
80
- config = saver._config
55
+ TAG = "[exporter]"
56
+ config = exporter._config
81
57
  # --- Paths & options ---
82
- raw_base = saver.raw_data_dir / site / book_id
83
- out_dir = saver.output_dir
58
+ raw_base = exporter._raw_data_dir / book_id
59
+ img_dir = exporter._cache_dir / book_id / "images"
60
+ out_dir = exporter.output_dir
61
+ img_dir.mkdir(parents=True, exist_ok=True)
84
62
  out_dir.mkdir(parents=True, exist_ok=True)
85
63
 
86
64
  # --- Load book_info.json ---
@@ -89,21 +67,27 @@ def common_save_as_epub(
89
67
  info_text = info_path.read_text(encoding="utf-8")
90
68
  book_info = json.loads(info_text)
91
69
  except Exception as e:
92
- saver.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
70
+ exporter.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
93
71
  return
94
72
 
95
73
  book_name = book_info.get("book_name", book_id)
96
- saver.logger.info(
74
+ exporter.logger.info(
97
75
  "%s Starting EPUB generation: %s (ID: %s)", TAG, book_name, book_id
98
76
  )
99
77
 
100
78
  # --- Generate intro + cover ---
101
79
  intro_html = generate_book_intro_html(book_info)
102
80
  cover_path: Path | None = None
103
- if config.include_cover:
104
- cover_filename = _image_url_to_filename(book_info.get("cover_url", ""))
105
- if cover_filename:
106
- cover_path = raw_base / cover_filename
81
+ cover_url = book_info.get("cover_url", "")
82
+ if config.include_cover and cover_url:
83
+ cover_path = download_image(
84
+ cover_url,
85
+ raw_base,
86
+ target_name="cover",
87
+ on_exist="overwrite",
88
+ )
89
+ if not cover_path:
90
+ exporter.logger.warning("Failed to download cover from %s", cover_url)
107
91
 
108
92
  # --- Initialize EPUB ---
109
93
  book, spine, toc_list = init_epub(
@@ -124,7 +108,7 @@ def common_save_as_epub(
124
108
  for vol_index, vol in enumerate(volumes, start=1):
125
109
  raw_vol_name = vol.get("volume_name", "").strip()
126
110
  vol_name = clean_chapter_title(raw_vol_name) or f"Unknown Volume {vol_index}"
127
- saver.logger.info("Processing volume %d: %s", vol_index, vol_name)
111
+ exporter.logger.info("Processing volume %d: %s", vol_index, vol_name)
128
112
 
129
113
  # Volume intro
130
114
  vol_intro = epub.EpubHtml(
@@ -148,12 +132,12 @@ def common_save_as_epub(
148
132
  chap_id = chap.get("chapterId")
149
133
  chap_title = chap.get("title", "")
150
134
  if not chap_id:
151
- saver.logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
135
+ exporter.logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
152
136
  continue
153
137
 
154
- chapter_data = saver._get_chapter(book_id, chap_id)
138
+ chapter_data = exporter._get_chapter(book_id, chap_id)
155
139
  if not chapter_data:
156
- saver.logger.info(
140
+ exporter.logger.info(
157
141
  "%s Missing chapter file: %s (%s), skipping.",
158
142
  TAG,
159
143
  chap_title,
@@ -162,9 +146,11 @@ def common_save_as_epub(
162
146
  continue
163
147
 
164
148
  title = clean_chapter_title(chapter_data.get("title", "")) or chap_id
149
+ content: str = chapter_data.get("content", "")
150
+ content = inline_remote_images(content, img_dir)
165
151
  chap_html = chapter_txt_to_html(
166
152
  chapter_title=title,
167
- chapter_text=chapter_data.get("content", ""),
153
+ chapter_text=content,
168
154
  author_say=chapter_data.get("author_say", ""),
169
155
  )
170
156
 
@@ -182,14 +168,16 @@ def common_save_as_epub(
182
168
 
183
169
  toc_list.append((section, chapter_items))
184
170
 
171
+ book = add_images_from_dir(book, img_dir)
172
+
185
173
  # --- 5. Finalize EPUB ---
186
- saver.logger.info("%s Building TOC and spine...", TAG)
174
+ exporter.logger.info("%s Building TOC and spine...", TAG)
187
175
  book.toc = toc_list
188
176
  book.spine = spine
189
177
  book.add_item(epub.EpubNcx())
190
178
  book.add_item(epub.EpubNav())
191
179
 
192
- out_name = saver.get_filename(
180
+ out_name = exporter.get_filename(
193
181
  title=book_name,
194
182
  author=book_info.get("author"),
195
183
  ext="epub",
@@ -198,7 +186,7 @@ def common_save_as_epub(
198
186
 
199
187
  try:
200
188
  epub.write_epub(out_path, book, EPUB_OPTIONS)
201
- saver.logger.info("%s EPUB successfully written to %s", TAG, out_path)
189
+ exporter.logger.info("%s EPUB successfully written to %s", TAG, out_path)
202
190
  except Exception as e:
203
- saver.logger.error("%s Failed to write EPUB to %s: %s", TAG, out_path, e)
191
+ exporter.logger.error("%s Failed to write EPUB to %s: %s", TAG, out_path, e)
204
192
  return
@@ -1,52 +1,44 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.savers.common.main_saver
4
- ----------------------------------------------
3
+ novel_downloader.core.exporters.common.main_exporter
4
+ ----------------------------------------------------
5
5
 
6
- This module implements the `QidianSaver` class, a concrete saver for handling
7
- novel data from Qidian (起点中文网). It defines the logic to compile, structure,
8
- and export novel content in plain text format based on the platform's metadata
9
- and chapter files.
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.
10
9
  """
11
10
 
12
11
  from collections.abc import Mapping
13
12
  from typing import Any
14
13
 
15
- from novel_downloader.config.models import SaverConfig
14
+ from novel_downloader.core.exporters.base import BaseExporter
15
+ from novel_downloader.models import ExporterConfig
16
16
  from novel_downloader.utils.chapter_storage import ChapterStorage
17
17
 
18
- from ..base import BaseSaver
19
- from .txt import common_save_as_txt
18
+ from .txt import common_export_as_txt
20
19
 
21
20
 
22
- class CommonSaver(BaseSaver):
21
+ class CommonExporter(BaseExporter):
23
22
  """
24
- CommonSaver is a saver that processes and exports novels.
25
- It extends the BaseSaver interface and provides
26
- logic for exporting full novels as plain text (.txt) files.
23
+ CommonExporter is a exporter that processes and exports novels.
24
+ It extends the BaseExporter interface and provides
25
+ logic for exporting full novels as plain text (.txt) files
26
+ and EPUB (.epub) files.
27
27
  """
28
28
 
29
29
  def __init__(
30
30
  self,
31
- config: SaverConfig,
31
+ config: ExporterConfig,
32
32
  site: str,
33
33
  chap_folders: list[str] | None = None,
34
34
  ):
35
- """
36
- Initialize the common saver with site information.
37
-
38
- :param config: A SaverConfig object that defines
39
- save paths, formats, and options.
40
- :param site: Identifier for the site the saver is handling.
41
- """
42
- super().__init__(config)
43
- self._site = site
35
+ super().__init__(config, site)
44
36
  self._chapter_storage_cache: dict[str, list[ChapterStorage]] = {}
45
37
  self._chap_folders: list[str] = chap_folders or ["chapters"]
46
38
 
47
- def save_as_txt(self, book_id: str) -> None:
39
+ def export_as_txt(self, book_id: str) -> None:
48
40
  """
49
- Compile and save a complete novel as a single .txt file.
41
+ Compile and export a complete novel as a single .txt file.
50
42
 
51
43
  Processing steps:
52
44
  1. Load book metadata from `book_info.json`, including title,
@@ -60,9 +52,9 @@ class CommonSaver(BaseSaver):
60
52
  :param book_id: The book identifier (used to locate raw data)
61
53
  """
62
54
  self._init_chapter_storages(book_id)
63
- return common_save_as_txt(self, book_id)
55
+ return common_export_as_txt(self, book_id)
64
56
 
65
- def save_as_epub(self, book_id: str) -> None:
57
+ def export_as_epub(self, book_id: str) -> None:
66
58
  """
67
59
  Persist the assembled book as a EPUB (.epub) file.
68
60
 
@@ -70,14 +62,14 @@ class CommonSaver(BaseSaver):
70
62
  :raises NotImplementedError: If the method is not overridden.
71
63
  """
72
64
  try:
73
- from .epub import common_save_as_epub
65
+ from .epub import common_export_as_epub
74
66
  except ImportError as err:
75
67
  raise NotImplementedError(
76
68
  "EPUB export not supported. Please install 'ebooklib'"
77
69
  ) from err
78
70
 
79
71
  self._init_chapter_storages(book_id)
80
- return common_save_as_epub(self, book_id)
72
+ return common_export_as_epub(self, book_id)
81
73
 
82
74
  @property
83
75
  def site(self) -> str:
@@ -88,15 +80,6 @@ class CommonSaver(BaseSaver):
88
80
  """
89
81
  return self._site
90
82
 
91
- @site.setter
92
- def site(self, value: str) -> None:
93
- """
94
- Set the site identifier.
95
-
96
- :param value: New site string to set.
97
- """
98
- self._site = value
99
-
100
83
  def _get_chapter(
101
84
  self,
102
85
  book_id: str,
@@ -109,7 +92,9 @@ class CommonSaver(BaseSaver):
109
92
  return {}
110
93
 
111
94
  def _init_chapter_storages(self, book_id: str) -> None:
112
- raw_base = self.raw_data_dir / self._site / book_id
95
+ if book_id in self._chapter_storage_cache:
96
+ return
97
+ raw_base = self._raw_data_dir / book_id
113
98
  self._chapter_storage_cache[book_id] = [
114
99
  ChapterStorage(
115
100
  raw_base=raw_base,
@@ -118,3 +103,15 @@ class CommonSaver(BaseSaver):
118
103
  )
119
104
  for ns in self._chap_folders
120
105
  ]
106
+
107
+ def _on_close(self) -> None:
108
+ """
109
+ Close all ChapterStorage connections in the cache.
110
+ """
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()
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.savers.common.txt
4
- ---------------------------------------
3
+ novel_downloader.core.exporters.common.txt
4
+ ------------------------------------------
5
5
 
6
6
  Contains the logic for exporting novel content as a single `.txt` file.
7
7
 
8
8
  This module defines `common_save_as_txt` function, which assembles and formats
9
9
  a novel based on metadata and chapter files found in the raw data directory.
10
- It is intended to be used by `CommonSaver` as part of the save/export process.
10
+ It is intended to be used by `CommonExporter` as part of the save/export process.
11
11
  """
12
12
 
13
13
  from __future__ import annotations
@@ -22,33 +22,29 @@ from novel_downloader.utils.text_utils import (
22
22
  )
23
23
 
24
24
  if TYPE_CHECKING:
25
- from .main_saver import CommonSaver
25
+ from .main_exporter import CommonExporter
26
26
 
27
27
 
28
- def common_save_as_txt(
29
- saver: CommonSaver,
28
+ def common_export_as_txt(
29
+ exporter: CommonExporter,
30
30
  book_id: str,
31
31
  ) -> None:
32
32
  """
33
33
  将 save_path 文件夹中该小说的所有章节 json 文件合并保存为一个完整的 txt 文件,
34
34
  并保存到 out_path 下
35
- 假设章节文件名格式为 `{chapterId}.json`
36
35
 
37
- 处理流程:
36
+ 处理流程:
38
37
  1. 从 book_info.json 中加载书籍信息 (包含书名、作者、简介及卷章节列表)
39
- 2. 遍历各卷, 每个卷先追加卷标题, 然后依次追加该卷下各章节的标题和内容,
40
- 同时记录最后一个章节标题作为“原文截至”
41
- 3. 将书籍元信息 (书名、作者、原文截至、内容简介) 与所有章节内容拼接,
42
- 构成最终完整文本
38
+ 2. 遍历各卷, 每个卷先追加卷标题, 然后依次追加该卷下各章节的标题和内容
39
+ 3. 将书籍元信息 (书名、作者、原文截至、内容简介) 与所有章节内容拼接
43
40
  4. 将最终结果保存到 out_path 下 (例如:`{book_name}.txt`)
44
41
 
45
42
  :param book_id: Identifier of the novel (used as subdirectory name).
46
43
  """
47
- TAG = "[saver]"
48
- site = saver.site
44
+ TAG = "[Exporter]"
49
45
  # --- Paths & options ---
50
- raw_base = saver.raw_data_dir / site / book_id
51
- out_dir = saver.output_dir
46
+ raw_base = exporter._raw_data_dir / book_id
47
+ out_dir = exporter.output_dir
52
48
  out_dir.mkdir(parents=True, exist_ok=True)
53
49
 
54
50
  # --- Load book_info.json ---
@@ -57,7 +53,7 @@ def common_save_as_txt(
57
53
  info_text = info_path.read_text(encoding="utf-8")
58
54
  book_info = json.loads(info_text)
59
55
  except Exception as e:
60
- saver.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
56
+ exporter.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
61
57
  return
62
58
 
63
59
  # --- Compile chapters ---
@@ -71,17 +67,17 @@ def common_save_as_txt(
71
67
  if vol_name:
72
68
  volume_header = f"\n\n{'=' * 6} {vol_name} {'=' * 6}\n\n"
73
69
  parts.append(volume_header)
74
- saver.logger.info("%s Processing volume: %s", TAG, vol_name)
70
+ exporter.logger.info("%s Processing volume: %s", TAG, vol_name)
75
71
  for chap in vol.get("chapters", []):
76
72
  chap_id = chap.get("chapterId")
77
73
  chap_title = chap.get("title", "")
78
74
  if not chap_id:
79
- saver.logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
75
+ exporter.logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
80
76
  continue
81
77
 
82
- chapter_data = saver._get_chapter(book_id, chap_id)
78
+ chapter_data = exporter._get_chapter(book_id, chap_id)
83
79
  if not chapter_data:
84
- saver.logger.info(
80
+ exporter.logger.info(
85
81
  "%s Missing chapter file in: %s (%s), skipping.",
86
82
  TAG,
87
83
  chap_title,
@@ -134,13 +130,13 @@ def common_save_as_txt(
134
130
  final_text = header + "\n\n" + "\n\n".join(parts).strip()
135
131
 
136
132
  # --- Determine output file path ---
137
- out_name = saver.get_filename(title=name, author=author, ext="txt")
133
+ out_name = exporter.get_filename(title=name, author=author, ext="txt")
138
134
  out_path = out_dir / out_name
139
135
 
140
136
  # --- Save final text ---
141
137
  try:
142
138
  save_as_txt(content=final_text, filepath=out_path)
143
- saver.logger.info("%s Novel saved to: %s", TAG, out_path)
139
+ exporter.logger.info("%s Novel saved to: %s", TAG, out_path)
144
140
  except Exception as e:
145
- saver.logger.error("%s Failed to save file: %s", TAG, e)
141
+ exporter.logger.error("%s Failed to save file: %s", TAG, e)
146
142
  return
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.exporters.epub_utils
4
+ ------------------------------------------
5
+
6
+ This package provides utility functions for constructing EPUB files,
7
+ including:
8
+
9
+ - CSS inclusion (css_builder)
10
+ - Image embedding (image_loader)
11
+ - EPUB book initialization (initializer)
12
+ - Chapter text-to-HTML conversion (text_to_html)
13
+ - Volume intro HTML generation (volume_intro)
14
+ """
15
+
16
+ from .css_builder import create_css_items
17
+ from .image_loader import (
18
+ add_images_from_dir,
19
+ add_images_from_dirs,
20
+ add_images_from_list,
21
+ )
22
+ from .initializer import init_epub
23
+ from .text_to_html import (
24
+ chapter_txt_to_html,
25
+ generate_book_intro_html,
26
+ inline_remote_images,
27
+ )
28
+ from .volume_intro import create_volume_intro
29
+
30
+ __all__ = [
31
+ "create_css_items",
32
+ "add_images_from_dir",
33
+ "add_images_from_dirs",
34
+ "add_images_from_list",
35
+ "init_epub",
36
+ "chapter_txt_to_html",
37
+ "create_volume_intro",
38
+ "generate_book_intro_html",
39
+ "inline_remote_images",
40
+ ]
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.savers.epub_utils.css_builder
3
+ novel_downloader.core.exporters.epub_utils.css_builder
4
+ ------------------------------------------------------
4
5
 
5
6
  Reads local CSS files and wraps them into epub.EpubItem objects,
6
7
  returning a list ready to be added to the EPUB.
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.exporters.epub_utils.image_loader
4
+ -------------------------------------------------------
5
+
6
+ Utilities for embedding image files into an EpubBook.
7
+ """
8
+
9
+ import logging
10
+ from collections.abc import Iterable, Sequence
11
+ from pathlib import Path
12
+
13
+ from ebooklib import epub
14
+
15
+ from novel_downloader.utils.constants import EPUB_IMAGE_FOLDER
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _SUPPORTED_IMAGE_MEDIA_TYPES: dict[str, str] = {
20
+ "png": "image/png",
21
+ "jpg": "image/jpeg",
22
+ "jpeg": "image/jpeg",
23
+ "gif": "image/gif",
24
+ "svg": "image/svg+xml",
25
+ "webp": "image/webp",
26
+ }
27
+ _DEFAULT_IMAGE_MEDIA_TYPE = "image/jpeg"
28
+
29
+
30
+ def add_images_from_list(
31
+ book: epub.EpubBook,
32
+ image_list: Sequence[str | Path],
33
+ ) -> epub.EpubBook:
34
+ """
35
+ Add a list of image files to the EPUB's image folder.
36
+
37
+ :param book: The EpubBook object to modify.
38
+ :param image_list: List of paths to image files.
39
+ :return: The same EpubBook instance, with images added.
40
+ """
41
+ for img_path in image_list:
42
+ img_path = Path(img_path)
43
+ if not img_path.is_file():
44
+ continue
45
+
46
+ suffix = img_path.suffix.lower().lstrip(".")
47
+ media_type = _SUPPORTED_IMAGE_MEDIA_TYPES.get(suffix)
48
+ if media_type is None:
49
+ media_type = _DEFAULT_IMAGE_MEDIA_TYPE
50
+ logger.warning(
51
+ "Unknown image suffix '%s' - defaulting media_type to %s",
52
+ suffix,
53
+ media_type,
54
+ )
55
+
56
+ try:
57
+ content = img_path.read_bytes()
58
+ item = epub.EpubItem(
59
+ uid=f"img_{img_path.stem}",
60
+ file_name=f"{EPUB_IMAGE_FOLDER}/{img_path.name}",
61
+ media_type=media_type,
62
+ content=content,
63
+ )
64
+ book.add_item(item)
65
+ logger.debug("Embedded image: %s", img_path.name)
66
+ except Exception:
67
+ logger.exception("Failed to embed image %s", img_path)
68
+
69
+ return book
70
+
71
+
72
+ def add_images_from_dir(
73
+ book: epub.EpubBook,
74
+ image_dir: str | Path,
75
+ ) -> epub.EpubBook:
76
+ """
77
+ Load every file in `image_dir` into the EPUB's image folder.
78
+
79
+ :param book: The EpubBook object to modify.
80
+ :param image_dir: Path to the directory containing image files.
81
+ :return: The same EpubBook instance, with images added.
82
+ """
83
+ image_dir = Path(image_dir)
84
+ if not image_dir.is_dir():
85
+ logger.warning("Image directory not found or not a directory: %s", image_dir)
86
+ return book
87
+
88
+ for img_path in image_dir.iterdir():
89
+ if not img_path.is_file():
90
+ continue
91
+
92
+ suffix = img_path.suffix.lower().lstrip(".")
93
+ media_type = _SUPPORTED_IMAGE_MEDIA_TYPES.get(suffix)
94
+ if media_type is None:
95
+ media_type = _DEFAULT_IMAGE_MEDIA_TYPE
96
+ logger.warning(
97
+ "Unknown image suffix '%s' - defaulting media_type to %s",
98
+ suffix,
99
+ media_type,
100
+ )
101
+
102
+ try:
103
+ content = img_path.read_bytes()
104
+ item = epub.EpubItem(
105
+ uid=f"img_{img_path.stem}",
106
+ file_name=f"{EPUB_IMAGE_FOLDER}/{img_path.name}",
107
+ media_type=media_type,
108
+ content=content,
109
+ )
110
+ book.add_item(item)
111
+ logger.debug("Embedded image: %s", img_path.name)
112
+ except Exception:
113
+ logger.exception("Failed to embed image %s", img_path)
114
+
115
+ return book
116
+
117
+
118
+ def add_images_from_dirs(
119
+ book: epub.EpubBook,
120
+ image_dirs: Iterable[str | Path],
121
+ ) -> epub.EpubBook:
122
+ """
123
+ Add all images from multiple directories into the given EpubBook.
124
+
125
+ :param book: The EpubBook object to modify.
126
+ :param image_dirs: An iterable of directory paths to scan for images.
127
+ :return: The same EpubBook instance, with all images added.
128
+ """
129
+ for img_dir in image_dirs:
130
+ book = add_images_from_dir(book, img_dir)
131
+ return book
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.savers.epub_utils.initializer
3
+ novel_downloader.core.exporters.epub_utils.initializer
4
+ ------------------------------------------------------
4
5
 
5
6
  Initializes an epub.EpubBook object, sets metadata
6
7
  (identifier, title, author, language, description),
@@ -41,10 +42,12 @@ def init_epub(
41
42
  """
42
43
  book = epub.EpubBook()
43
44
  book.set_identifier(str(book_id))
44
- book.set_title(book_info.get("book_name", "未找到书名"))
45
+ book_name = book_info.get("book_name") or book_info.get("volume_name", "未找到书名")
46
+ book.set_title(book_name)
45
47
  book.set_language("zh-CN")
46
48
  book.add_author(book_info.get("author", "未找到作者"))
47
- book.add_metadata("DC", "description", book_info.get("summary", "未找到作品简介"))
49
+ desc = book_info.get("summary") or book_info.get("volume_intro", "未找到作品简介")
50
+ book.add_metadata("DC", "description", desc)
48
51
 
49
52
  spine = []
50
53