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
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.savers.epub_utils.text_to_html
3
+ novel_downloader.core.exporters.epub_utils.text_to_html
4
+ -------------------------------------------------------
4
5
 
5
6
  Module for converting raw chapter text to formatted HTML,
6
7
  with automatic word correction and optional image/tag support.
@@ -8,13 +9,23 @@ with automatic word correction and optional image/tag support.
8
9
 
9
10
  import json
10
11
  import logging
12
+ import re
13
+ from pathlib import Path
11
14
  from typing import Any
12
15
 
13
- from novel_downloader.utils.constants import REPLACE_WORD_MAP_PATH
16
+ from novel_downloader.utils.constants import (
17
+ EPUB_IMAGE_WRAPPER,
18
+ REPLACE_WORD_MAP_PATH,
19
+ )
20
+ from novel_downloader.utils.network import download_image
14
21
  from novel_downloader.utils.text_utils import diff_inline_display
15
22
 
16
23
  logger = logging.getLogger(__name__)
17
24
 
25
+ _IMG_TAG_PATTERN = re.compile(
26
+ r'<img\s+[^>]*src=[\'"]([^\'"]+)[\'"][^>]*>', re.IGNORECASE
27
+ )
28
+
18
29
 
19
30
  # Load and sort replacement map from JSON
20
31
  try:
@@ -87,6 +98,42 @@ def chapter_txt_to_html(
87
98
  return "\n".join(html_parts)
88
99
 
89
100
 
101
+ def inline_remote_images(
102
+ content: str,
103
+ image_dir: str | Path,
104
+ ) -> str:
105
+ """
106
+ Download every remote `<img src="...">` in `content` into `image_dir`,
107
+ and replace the original tag with EPUB_IMAGE_WRAPPER
108
+ pointing to the local filename.
109
+
110
+ :param content: HTML/text of the chapter containing <img> tags.
111
+ :param image_dir: Directory to save downloaded images into.
112
+ :return: Modified content with local image references.
113
+ """
114
+
115
+ def _replace(match: re.Match[str]) -> str:
116
+ url = match.group(1)
117
+ try:
118
+ # download_image returns a Path or None
119
+ local_path = download_image(
120
+ url, image_dir, target_name=None, on_exist="skip"
121
+ )
122
+ if not local_path:
123
+ logger.warning(
124
+ "Failed to download image, leaving original tag: %s", url
125
+ )
126
+ return match.group(0)
127
+
128
+ # wrap with the EPUB_IMAGE_WRAPPER, inserting just the filename
129
+ return EPUB_IMAGE_WRAPPER.format(filename=local_path.name)
130
+ except Exception:
131
+ logger.exception("Error processing image URL: %s", url)
132
+ return match.group(0)
133
+
134
+ return _IMG_TAG_PATTERN.sub(_replace, content)
135
+
136
+
90
137
  def generate_book_intro_html(book_info: dict[str, Any]) -> str:
91
138
  """
92
139
  Generate HTML string for a book's information and summary.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.savers.epub_utils.volume_intro
3
+ novel_downloader.core.exporters.epub_utils.volume_intro
4
+ -------------------------------------------------------
4
5
 
5
6
  Responsible for generating HTML code for volume introduction pages,
6
7
  including two style variants and a unified entry point.
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.exporters.esjzone
4
+ ---------------------------------------
5
+
6
+ """
7
+
8
+ from novel_downloader.models import ExporterConfig
9
+
10
+ from .common import CommonExporter
11
+
12
+
13
+ class EsjzoneExporter(CommonExporter):
14
+ def __init__(
15
+ self,
16
+ config: ExporterConfig,
17
+ ):
18
+ super().__init__(
19
+ config,
20
+ site="esjzone",
21
+ chap_folders=["chapters"],
22
+ )
23
+
24
+
25
+ __all__ = ["EsjzoneExporter"]
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.exporters.linovelib
4
+ -----------------------------------------
5
+
6
+ """
7
+
8
+ from .main_exporter import LinovelibExporter
9
+
10
+ __all__ = ["LinovelibExporter"]
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.exporters.linovelib.epub
4
+ ----------------------------------------------
5
+
6
+ Contains the logic for exporting novel content as a single `.epub` file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import html
12
+ import json
13
+ import re
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from ebooklib import epub
18
+
19
+ from novel_downloader.core.exporters.epub_utils import (
20
+ add_images_from_dir,
21
+ add_images_from_list,
22
+ chapter_txt_to_html,
23
+ create_css_items,
24
+ create_volume_intro,
25
+ init_epub,
26
+ )
27
+ from novel_downloader.utils.constants import (
28
+ DEFAULT_HEADERS,
29
+ EPUB_IMAGE_FOLDER,
30
+ EPUB_IMAGE_WRAPPER,
31
+ EPUB_OPTIONS,
32
+ EPUB_TEXT_FOLDER,
33
+ )
34
+ from novel_downloader.utils.file_utils import sanitize_filename
35
+ from novel_downloader.utils.network import download_image
36
+
37
+ if TYPE_CHECKING:
38
+ from .main_exporter import LinovelibExporter
39
+
40
+ _IMG_TAG_PATTERN = re.compile(
41
+ r'<img\s+[^>]*src=[\'"]([^\'"]+)[\'"][^>]*>', re.IGNORECASE
42
+ )
43
+ _IMG_HEADERS = DEFAULT_HEADERS.copy()
44
+ _IMG_HEADERS["Referer"] = "https://www.linovelib.com/"
45
+
46
+
47
+ def export_whole_book(
48
+ exporter: LinovelibExporter,
49
+ book_id: str,
50
+ ) -> None:
51
+ """
52
+ Export a single novel (identified by `book_id`) to an EPUB file.
53
+
54
+ This function will:
55
+ 1. Load `book_info.json` for metadata.
56
+ 2. Generate introductory HTML and optionally include the cover image.
57
+ 3. Initialize the EPUB container.
58
+ 4. Iterate through volumes and chapters, convert each to XHTML.
59
+ 5. Assemble the spine, TOC, CSS and write out the final `.epub`.
60
+
61
+ :param book_id: Identifier of the novel (used as subdirectory name).
62
+ """
63
+ TAG = "[exporter]"
64
+ config = exporter._config
65
+ # --- Paths & options ---
66
+ raw_base = exporter._raw_data_dir / book_id
67
+ img_dir = exporter._cache_dir / book_id / "images"
68
+ out_dir = exporter.output_dir
69
+ img_dir.mkdir(parents=True, exist_ok=True)
70
+ out_dir.mkdir(parents=True, exist_ok=True)
71
+
72
+ # --- Load book_info.json ---
73
+ info_path = raw_base / "book_info.json"
74
+ try:
75
+ info_text = info_path.read_text(encoding="utf-8")
76
+ book_info = json.loads(info_text)
77
+ except Exception as e:
78
+ exporter.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
79
+ return
80
+
81
+ book_name = book_info.get("book_name", book_id)
82
+ exporter.logger.info(
83
+ "%s Starting EPUB generation: %s (ID: %s)", TAG, book_name, book_id
84
+ )
85
+
86
+ # --- Generate intro + cover ---
87
+ intro_html = _generate_intro_html(book_info)
88
+ cover_path: Path | None = None
89
+ cover_url = book_info.get("cover_url", "")
90
+ if config.include_cover and cover_url:
91
+ cover_path = download_image(
92
+ cover_url,
93
+ raw_base,
94
+ target_name="cover",
95
+ headers=_IMG_HEADERS,
96
+ on_exist="overwrite",
97
+ )
98
+ if not cover_path:
99
+ exporter.logger.warning("Failed to download cover from %s", cover_url)
100
+
101
+ # --- Initialize EPUB ---
102
+ book, spine, toc_list = init_epub(
103
+ book_info=book_info,
104
+ book_id=book_id,
105
+ intro_html=intro_html,
106
+ book_cover_path=cover_path,
107
+ include_toc=config.include_toc,
108
+ )
109
+ for css in create_css_items(
110
+ include_main=True,
111
+ include_volume=True,
112
+ ):
113
+ book.add_item(css)
114
+
115
+ # --- Compile chapters ---
116
+ volumes = book_info.get("volumes", [])
117
+ for vol_index, vol in enumerate(volumes, start=1):
118
+ vol_name = vol.get("volume_name", "").strip() or f"Unknown Volume {vol_index}"
119
+ vol_name = vol_name.replace(book_name, "").strip()
120
+ vol_cover_path: Path | None = None
121
+ vol_cover_url = vol.get("volume_cover", "")
122
+ if config.include_cover and vol_cover_url:
123
+ vol_cover_path = download_image(
124
+ vol_cover_url,
125
+ img_dir,
126
+ headers=_IMG_HEADERS,
127
+ on_exist="skip",
128
+ )
129
+
130
+ exporter.logger.info("Processing volume %d: %s", vol_index, vol_name)
131
+
132
+ # Volume intro
133
+ vol_intro = epub.EpubHtml(
134
+ title=vol_name,
135
+ file_name=f"{EPUB_TEXT_FOLDER}/volume_intro_{vol_index}.xhtml",
136
+ lang="zh",
137
+ )
138
+ vol_intro.content = _generate_vol_intro_html(
139
+ vol_name,
140
+ vol.get("volume_intro", ""),
141
+ vol_cover_path,
142
+ )
143
+ vol_intro.add_link(
144
+ href="../Styles/volume-intro.css",
145
+ rel="stylesheet",
146
+ type="text/css",
147
+ )
148
+ book.add_item(vol_intro)
149
+ spine.append(vol_intro)
150
+
151
+ section = epub.Section(vol_name, vol_intro.file_name)
152
+ chapter_items: list[epub.EpubHtml] = []
153
+
154
+ for chap in vol.get("chapters", []):
155
+ chap_id = chap.get("chapterId")
156
+ chap_title = chap.get("title", "")
157
+ if not chap_id:
158
+ exporter.logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
159
+ continue
160
+
161
+ chapter_data = exporter._get_chapter(book_id, chap_id)
162
+ if not chapter_data:
163
+ exporter.logger.info(
164
+ "%s Missing chapter file: %s (%s), skipping.",
165
+ TAG,
166
+ chap_title,
167
+ chap_id,
168
+ )
169
+ continue
170
+
171
+ title = chapter_data.get("title", "") or chap_id
172
+ content: str = chapter_data.get("content", "")
173
+ content, _ = _inline_remote_images(content, img_dir)
174
+ chap_html = chapter_txt_to_html(
175
+ chapter_title=title,
176
+ chapter_text=content,
177
+ author_say="",
178
+ )
179
+
180
+ chap_path = f"{EPUB_TEXT_FOLDER}/{chap_id}.xhtml"
181
+ item = epub.EpubHtml(title=chap_title, file_name=chap_path, lang="zh")
182
+ item.content = chap_html
183
+ item.add_link(
184
+ href="../Styles/main.css",
185
+ rel="stylesheet",
186
+ type="text/css",
187
+ )
188
+ book.add_item(item)
189
+ spine.append(item)
190
+ chapter_items.append(item)
191
+
192
+ toc_list.append((section, chapter_items))
193
+
194
+ book = add_images_from_dir(book, img_dir)
195
+
196
+ # --- 5. Finalize EPUB ---
197
+ exporter.logger.info("%s Building TOC and spine...", TAG)
198
+ book.toc = toc_list
199
+ book.spine = spine
200
+ book.add_item(epub.EpubNcx())
201
+ book.add_item(epub.EpubNav())
202
+
203
+ out_name = exporter.get_filename(
204
+ title=book_name,
205
+ author=book_info.get("author"),
206
+ ext="epub",
207
+ )
208
+ out_path = out_dir / sanitize_filename(out_name)
209
+
210
+ try:
211
+ epub.write_epub(out_path, book, EPUB_OPTIONS)
212
+ exporter.logger.info("%s EPUB successfully written to %s", TAG, out_path)
213
+ except Exception as e:
214
+ exporter.logger.error("%s Failed to write EPUB to %s: %s", TAG, out_path, e)
215
+ return
216
+
217
+
218
+ def export_by_volume(
219
+ exporter: LinovelibExporter,
220
+ book_id: str,
221
+ ) -> None:
222
+ """
223
+ Export a single novel (identified by `book_id`) to multi EPUB file.
224
+
225
+ :param book_id: Identifier of the novel (used as subdirectory name).
226
+ """
227
+ TAG = "[exporter]"
228
+ config = exporter._config
229
+ # --- Paths & options ---
230
+ raw_base = exporter._raw_data_dir / book_id
231
+ img_dir = exporter._cache_dir / book_id / "images"
232
+ out_dir = exporter.output_dir
233
+ img_dir.mkdir(parents=True, exist_ok=True)
234
+ out_dir.mkdir(parents=True, exist_ok=True)
235
+
236
+ # --- Load book_info.json ---
237
+ info_path = raw_base / "book_info.json"
238
+ try:
239
+ info_text = info_path.read_text(encoding="utf-8")
240
+ book_info = json.loads(info_text)
241
+ except Exception as e:
242
+ exporter.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
243
+ return
244
+
245
+ book_name = book_info.get("book_name", book_id)
246
+ exporter.logger.info(
247
+ "%s Starting EPUB generation: %s (ID: %s)", TAG, book_name, book_id
248
+ )
249
+ css_items = create_css_items(
250
+ include_main=True,
251
+ include_volume=True,
252
+ )
253
+
254
+ # --- Compile columes ---
255
+ volumes = book_info.get("volumes", [])
256
+ for vol_index, vol in enumerate(volumes, start=1):
257
+ vol_name = vol.get("volume_name", "").strip() or f"Unknown Volume {vol_index}"
258
+ vol_cover_path: Path | None = None
259
+ vol_cover_url = vol.get("volume_cover", "")
260
+ if config.include_cover and vol_cover_url:
261
+ vol_cover_path = download_image(
262
+ vol_cover_url,
263
+ img_dir,
264
+ headers=_IMG_HEADERS,
265
+ on_exist="skip",
266
+ )
267
+ intro_html = _generate_intro_html(vol)
268
+
269
+ book, spine, toc_list = init_epub(
270
+ book_info=vol,
271
+ book_id=f"{book_id}_{vol_index}",
272
+ intro_html=intro_html,
273
+ book_cover_path=vol_cover_path,
274
+ include_toc=config.include_toc,
275
+ )
276
+ for css in css_items:
277
+ book.add_item(css)
278
+
279
+ for chap in vol.get("chapters", []):
280
+ chap_id = chap.get("chapterId")
281
+ chap_title = chap.get("title", "")
282
+ if not chap_id:
283
+ exporter.logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
284
+ continue
285
+
286
+ chapter_data = exporter._get_chapter(book_id, chap_id)
287
+ if not chapter_data:
288
+ exporter.logger.info(
289
+ "%s Missing chapter file: %s (%s), skipping.",
290
+ TAG,
291
+ chap_title,
292
+ chap_id,
293
+ )
294
+ continue
295
+
296
+ title = chapter_data.get("title", "") or chap_id
297
+ content: str = chapter_data.get("content", "")
298
+ content, imgs = _inline_remote_images(content, img_dir)
299
+ chap_html = chapter_txt_to_html(
300
+ chapter_title=title,
301
+ chapter_text=content,
302
+ author_say="",
303
+ )
304
+ add_images_from_list(book, imgs)
305
+
306
+ chap_path = f"{EPUB_TEXT_FOLDER}/{chap_id}.xhtml"
307
+ item = epub.EpubHtml(title=chap_title, file_name=chap_path, lang="zh")
308
+ item.content = chap_html
309
+ item.add_link(
310
+ href="../Styles/main.css",
311
+ rel="stylesheet",
312
+ type="text/css",
313
+ )
314
+ book.add_item(item)
315
+ spine.append(item)
316
+ toc_list.append(item)
317
+
318
+ book.toc = toc_list
319
+ book.spine = spine
320
+ book.add_item(epub.EpubNcx())
321
+ book.add_item(epub.EpubNav())
322
+
323
+ out_name = exporter.get_filename(
324
+ title=vol_name,
325
+ author=book_info.get("author"),
326
+ ext="epub",
327
+ )
328
+ out_path = out_dir / sanitize_filename(out_name)
329
+
330
+ try:
331
+ epub.write_epub(out_path, book, EPUB_OPTIONS)
332
+ exporter.logger.info("%s EPUB successfully written to %s", TAG, out_path)
333
+ except Exception as e:
334
+ exporter.logger.error("%s Failed to write EPUB to %s: %s", TAG, out_path, e)
335
+ return
336
+
337
+
338
+ def _generate_intro_html(
339
+ info: dict[str, Any],
340
+ default_author: str = "",
341
+ ) -> str:
342
+ """
343
+ Generate an HTML snippet containing book metadata and summary.
344
+
345
+ :param info: A dict that may contain book info
346
+ :param default_author: Fallback author name.
347
+
348
+ :return: An HTML-formatted string.
349
+ """
350
+ title = info.get("book_name") or info.get("volume_name")
351
+ author = info.get("author") or default_author
352
+ status = info.get("serial_status")
353
+ words = info.get("word_count")
354
+ raw_summary = (info.get("summary") or info.get("volume_intro") or "").strip()
355
+
356
+ html_parts = [
357
+ "<h1>书籍简介</h1>",
358
+ '<div class="list">',
359
+ "<ul>",
360
+ ]
361
+ metadata = [
362
+ ("书名", title),
363
+ ("作者", author),
364
+ ("状态", status),
365
+ ("字数", words),
366
+ ]
367
+ for label, value in metadata:
368
+ if value is not None and str(value).strip():
369
+ safe = html.escape(str(value))
370
+ if label == "书名":
371
+ safe = f"《{safe}》"
372
+ html_parts.append(f"<li>{label}: {safe}</li>")
373
+
374
+ html_parts.extend(["</ul>", "</div>"])
375
+
376
+ if raw_summary:
377
+ html_parts.append('<p class="new-page-after"><br/></p>')
378
+ html_parts.append("<h2>简介</h2>")
379
+ for para in filter(None, (p.strip() for p in raw_summary.split("\n\n"))):
380
+ safe_para = html.escape(para).replace("\n", "<br/>")
381
+ html_parts.append(f"<p>{safe_para}</p>")
382
+
383
+ return "\n".join(html_parts)
384
+
385
+
386
+ def _generate_vol_intro_html(
387
+ title: str,
388
+ intro: str = "",
389
+ cover_path: Path | None = None,
390
+ ) -> str:
391
+ """
392
+ Generate the HTML snippet for a volume's introduction section.
393
+
394
+ :param title: Title of the volume.
395
+ :param intro: Optional introduction text for the volume.
396
+ :param cover_path: Path of the volume cover.
397
+ :return: HTML string representing the volume's intro section.
398
+ """
399
+ if cover_path is None:
400
+ return create_volume_intro(title, intro)
401
+
402
+ html_parts = [
403
+ f'<h1 class="volume-title-line1">{title}</h1>',
404
+ f'<img class="width100" src="../{EPUB_IMAGE_FOLDER}/{cover_path.name}" />',
405
+ '<p class="new-page-after"><br/></p>',
406
+ ]
407
+
408
+ if intro.strip():
409
+ html_parts.append(f'<p class="intro">{intro}</p>')
410
+
411
+ return "\n".join(html_parts)
412
+
413
+
414
+ def _inline_remote_images(
415
+ content: str,
416
+ image_dir: str | Path,
417
+ ) -> tuple[str, list[Path]]:
418
+ """
419
+ Download every remote `<img src="...">` in `content` into `image_dir`,
420
+ and replace the original tag with EPUB_IMAGE_WRAPPER
421
+ pointing to the local filename.
422
+
423
+ :param content: HTML/text of the chapter containing <img> tags.
424
+ :param image_dir: Directory to save downloaded images into.
425
+ :return: A tuple (modified_content, list_of_downloaded_image_paths).
426
+ """
427
+ downloaded_images: list[Path] = []
428
+
429
+ def _replace(match: re.Match[str]) -> str:
430
+ url = match.group(1)
431
+ try:
432
+ # download_image returns a Path or None
433
+ local_path = download_image(
434
+ url,
435
+ image_dir,
436
+ target_name=None,
437
+ headers=_IMG_HEADERS,
438
+ on_exist="skip",
439
+ )
440
+ if not local_path:
441
+ return match.group(0)
442
+
443
+ downloaded_images.append(local_path)
444
+ return EPUB_IMAGE_WRAPPER.format(filename=local_path.name)
445
+ except Exception:
446
+ return match.group(0)
447
+
448
+ modified_content = _IMG_TAG_PATTERN.sub(_replace, content)
449
+ return modified_content, downloaded_images
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.exporters.linovelib.main_exporter
4
+ -------------------------------------------------------
5
+
6
+ """
7
+
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+
11
+ from novel_downloader.core.exporters.base import BaseExporter
12
+ from novel_downloader.models import ExporterConfig
13
+ from novel_downloader.utils.chapter_storage import ChapterStorage
14
+
15
+ from .txt import linovelib_export_as_txt
16
+
17
+
18
+ class LinovelibExporter(BaseExporter):
19
+ """"""
20
+
21
+ def __init__(
22
+ self,
23
+ config: ExporterConfig,
24
+ ):
25
+ """
26
+ Initialize the linovelib exporter.
27
+
28
+ :param config: A ExporterConfig object that defines
29
+ save paths, formats, and options.
30
+ """
31
+ super().__init__(config, "linovelib")
32
+ self._chapter_storage_cache: dict[str, list[ChapterStorage]] = {}
33
+ self._chap_folders: list[str] = ["chapters"]
34
+
35
+ def export_as_txt(self, book_id: str) -> None:
36
+ """
37
+ Compile and export a novel as a single .txt file.
38
+
39
+ :param book_id: The book identifier (used to locate raw data)
40
+ """
41
+ self._init_chapter_storages(book_id)
42
+ return linovelib_export_as_txt(self, book_id)
43
+
44
+ def export_as_epub(self, book_id: str) -> None:
45
+ """
46
+ Persist the assembled book as a EPUB (.epub) file.
47
+
48
+ :param book_id: The book identifier.
49
+ :raises NotImplementedError: If the method is not overridden.
50
+ """
51
+ try:
52
+ from .epub import (
53
+ export_by_volume,
54
+ export_whole_book,
55
+ )
56
+ except ImportError as err:
57
+ raise NotImplementedError(
58
+ "EPUB export not supported. Please install 'ebooklib'"
59
+ ) from err
60
+
61
+ self._init_chapter_storages(book_id)
62
+
63
+ exporters = {
64
+ "volume": export_by_volume,
65
+ "book": export_whole_book,
66
+ }
67
+ try:
68
+ export_fn = exporters[self._config.split_mode]
69
+ except KeyError as err:
70
+ raise ValueError(
71
+ f"Unsupported split_mode: {self._config.split_mode!r}"
72
+ ) from err
73
+ return export_fn(self, book_id)
74
+
75
+ @property
76
+ def site(self) -> str:
77
+ """
78
+ Get the site identifier.
79
+
80
+ :return: The site string.
81
+ """
82
+ return self._site
83
+
84
+ @site.setter
85
+ def site(self, value: str) -> None:
86
+ """
87
+ Set the site identifier.
88
+
89
+ :param value: New site string to set.
90
+ """
91
+ self._site = value
92
+
93
+ def _get_chapter(
94
+ self,
95
+ book_id: str,
96
+ chap_id: str,
97
+ ) -> Mapping[str, Any]:
98
+ for storage in self._chapter_storage_cache[book_id]:
99
+ data = storage.get(chap_id)
100
+ if data:
101
+ return data
102
+ return {}
103
+
104
+ def _init_chapter_storages(self, book_id: str) -> None:
105
+ if book_id in self._chapter_storage_cache:
106
+ return
107
+ raw_base = self._raw_data_dir / book_id
108
+ self._chapter_storage_cache[book_id] = [
109
+ ChapterStorage(
110
+ raw_base=raw_base,
111
+ namespace=ns,
112
+ backend_type=self._config.storage_backend,
113
+ )
114
+ for ns in self._chap_folders
115
+ ]
116
+
117
+ def _on_close(self) -> None:
118
+ """
119
+ Close all ChapterStorage connections in the cache.
120
+ """
121
+ for storages in self._chapter_storage_cache.values():
122
+ for storage in storages:
123
+ try:
124
+ storage.close()
125
+ except Exception as e:
126
+ self.logger.warning("Failed to close storage %s: %s", storage, e)
127
+ self._chapter_storage_cache.clear()