novel-downloader 1.3.3__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 (211) 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 -39
  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 +22 -22
  27. novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +35 -40
  28. novel_downloader/core/{savers → exporters}/common/txt.py +20 -23
  29. novel_downloader/core/{savers → exporters}/epub_utils/__init__.py +8 -3
  30. novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -2
  31. novel_downloader/core/{savers → exporters}/epub_utils/image_loader.py +46 -4
  32. novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -4
  33. novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +3 -3
  34. novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -2
  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 +20 -14
  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 +11 -5
  127. novel_downloader/utils/cookies.py +66 -0
  128. novel_downloader/utils/crypto_utils.py +1 -74
  129. novel_downloader/utils/fontocr/ocr_v1.py +2 -1
  130. novel_downloader/utils/fontocr/ocr_v2.py +2 -2
  131. novel_downloader/utils/hash_store.py +10 -18
  132. novel_downloader/utils/hash_utils.py +3 -2
  133. novel_downloader/utils/logger.py +2 -3
  134. novel_downloader/utils/network.py +2 -1
  135. novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
  136. novel_downloader/utils/text_utils/font_mapping.py +1 -1
  137. novel_downloader/utils/text_utils/text_cleaning.py +1 -1
  138. novel_downloader/utils/time_utils/datetime_utils.py +3 -3
  139. novel_downloader/utils/time_utils/sleep_utils.py +1 -1
  140. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/METADATA +69 -35
  141. novel_downloader-1.4.0.dist-info/RECORD +170 -0
  142. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
  143. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/entry_points.txt +1 -0
  144. novel_downloader/cli/interactive.py +0 -66
  145. novel_downloader/cli/settings.py +0 -177
  146. novel_downloader/config/models.py +0 -187
  147. novel_downloader/core/downloaders/base/__init__.py +0 -14
  148. novel_downloader/core/downloaders/base/base_async.py +0 -153
  149. novel_downloader/core/downloaders/base/base_sync.py +0 -208
  150. novel_downloader/core/downloaders/biquge/__init__.py +0 -14
  151. novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
  152. novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
  153. novel_downloader/core/downloaders/common/__init__.py +0 -14
  154. novel_downloader/core/downloaders/common/common_async.py +0 -210
  155. novel_downloader/core/downloaders/common/common_sync.py +0 -202
  156. novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
  157. novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
  158. novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
  159. novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
  160. novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
  161. novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
  162. novel_downloader/core/downloaders/qidian/__init__.py +0 -10
  163. novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -219
  164. novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
  165. novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
  166. novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
  167. novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
  168. novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
  169. novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
  170. novel_downloader/core/factory/requester.py +0 -144
  171. novel_downloader/core/factory/saver.py +0 -56
  172. novel_downloader/core/interfaces/async_downloader.py +0 -36
  173. novel_downloader/core/interfaces/async_requester.py +0 -84
  174. novel_downloader/core/interfaces/sync_downloader.py +0 -36
  175. novel_downloader/core/interfaces/sync_requester.py +0 -82
  176. novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
  177. novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
  178. novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
  179. novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
  180. novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
  181. novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
  182. novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
  183. novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
  184. novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
  185. novel_downloader/core/requesters/base/async_session.py +0 -410
  186. novel_downloader/core/requesters/base/browser.py +0 -337
  187. novel_downloader/core/requesters/base/session.py +0 -378
  188. novel_downloader/core/requesters/biquge/__init__.py +0 -14
  189. novel_downloader/core/requesters/common/__init__.py +0 -17
  190. novel_downloader/core/requesters/common/session.py +0 -113
  191. novel_downloader/core/requesters/esjzone/__init__.py +0 -13
  192. novel_downloader/core/requesters/esjzone/session.py +0 -235
  193. novel_downloader/core/requesters/qianbi/__init__.py +0 -13
  194. novel_downloader/core/requesters/qidian/__init__.py +0 -21
  195. novel_downloader/core/requesters/qidian/broswer.py +0 -307
  196. novel_downloader/core/requesters/qidian/session.py +0 -290
  197. novel_downloader/core/requesters/sfacg/__init__.py +0 -13
  198. novel_downloader/core/requesters/sfacg/session.py +0 -242
  199. novel_downloader/core/requesters/yamibo/__init__.py +0 -13
  200. novel_downloader/core/requesters/yamibo/session.py +0 -237
  201. novel_downloader/core/savers/__init__.py +0 -34
  202. novel_downloader/core/savers/biquge.py +0 -25
  203. novel_downloader/core/savers/common/__init__.py +0 -12
  204. novel_downloader/core/savers/esjzone.py +0 -25
  205. novel_downloader/core/savers/qianbi.py +0 -25
  206. novel_downloader/core/savers/sfacg.py +0 -25
  207. novel_downloader/core/savers/yamibo.py +0 -25
  208. novel_downloader/resources/config/rules.toml +0 -196
  209. novel_downloader-1.3.3.dist-info/RECORD +0 -166
  210. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/licenses/LICENSE +0 -0
  211. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/top_level.txt +0 -0
@@ -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()
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.exporters.linovelib.txt
4
+ ---------------------------------------------
5
+
6
+ Contains the logic for exporting novel content as a single `.txt` file.
7
+
8
+ This module defines `linovelib_export_as_txt` function, which assembles and formats
9
+ a novel based on metadata and chapter files found in the raw data directory.
10
+ It is intended to be used by `LinovelibExporter` as part of the save/export process.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import TYPE_CHECKING
17
+
18
+ from novel_downloader.utils.file_utils import save_as_txt
19
+ from novel_downloader.utils.text_utils import (
20
+ format_chapter,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from .main_exporter import LinovelibExporter
25
+
26
+
27
+ def linovelib_export_as_txt(
28
+ exporter: LinovelibExporter,
29
+ book_id: str,
30
+ ) -> None:
31
+ """
32
+ 将 save_path 文件夹中该小说的所有章节 json 文件合并保存为一个完整的 txt 文件,
33
+ 并保存到 out_path 下
34
+
35
+ 处理流程:
36
+ 1. 从 book_info.json 中加载书籍信息 (包含书名、作者、简介及卷章节列表)
37
+ 2. 遍历各卷, 每个卷先追加卷标题, 然后依次追加该卷下各章节的标题和内容
38
+ 3. 将书籍元信息 (书名、作者、原文截至、内容简介) 与所有章节内容拼接
39
+ 4. 将最终结果保存到 out_path 下 (例如:`{book_name}.txt`)
40
+
41
+ :param book_id: Identifier of the novel (used as subdirectory name).
42
+ """
43
+ TAG = "[exporter]"
44
+ # --- Paths & options ---
45
+ raw_base = exporter._raw_data_dir / book_id
46
+ out_dir = exporter.output_dir
47
+ out_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ # --- Load book_info.json ---
50
+ info_path = raw_base / "book_info.json"
51
+ try:
52
+ info_text = info_path.read_text(encoding="utf-8")
53
+ book_info = json.loads(info_text)
54
+ except Exception as e:
55
+ exporter.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
56
+ return
57
+
58
+ # --- Compile chapters ---
59
+ parts: list[str] = []
60
+ volumes = book_info.get("volumes", [])
61
+
62
+ for vol in volumes:
63
+ vol_name = vol.get("volume_name", "").strip()
64
+ vol_intro = vol.get("volume_intro", "").strip()
65
+ if vol_name:
66
+ volume_header = f"\n\n{'=' * 6} {vol_name} {'=' * 6}\n\n"
67
+ parts.append(volume_header)
68
+ exporter.logger.info("%s Processing volume: %s", TAG, vol_name)
69
+ if vol_intro:
70
+ parts.append(f"{vol_intro}\n\n")
71
+ for chap in vol.get("chapters", []):
72
+ chap_id = chap.get("chapterId")
73
+ chap_title = chap.get("title", "")
74
+ if not chap_id:
75
+ exporter.logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
76
+ continue
77
+
78
+ chapter_data = exporter._get_chapter(book_id, chap_id)
79
+ if not chapter_data:
80
+ exporter.logger.info(
81
+ "%s Missing chapter file in: %s (%s), skipping.",
82
+ TAG,
83
+ chap_title,
84
+ chap_id,
85
+ )
86
+ continue
87
+
88
+ # Extract structured fields
89
+ title = chapter_data.get("title", chap_title).strip()
90
+ content = chapter_data.get("content", "").strip()
91
+
92
+ parts.append(format_chapter(title, content, ""))
93
+
94
+ # --- Build header ---
95
+ name = book_info.get("book_name")
96
+ author = book_info.get("author")
97
+ words = book_info.get("word_count")
98
+ updated = book_info.get("update_time")
99
+ summary = book_info.get("summary")
100
+
101
+ fields = [
102
+ ("书名", name),
103
+ ("作者", author),
104
+ ("总字数", words),
105
+ ("更新日期", updated),
106
+ ]
107
+ header_lines = [f"{label}: {value}" for label, value in fields if value]
108
+
109
+ if summary:
110
+ header_lines.append("内容简介:")
111
+ header_lines.append(summary)
112
+
113
+ header_lines += ["", "-" * 10, ""]
114
+
115
+ header = "\n".join(header_lines)
116
+
117
+ final_text = header + "\n\n" + "\n\n".join(parts).strip()
118
+
119
+ # --- Determine output file path ---
120
+ out_name = exporter.get_filename(title=name, author=author, ext="txt")
121
+ out_path = out_dir / out_name
122
+
123
+ # --- Save final text ---
124
+ try:
125
+ save_as_txt(content=final_text, filepath=out_path)
126
+ exporter.logger.info("%s Novel saved to: %s", TAG, out_path)
127
+ except Exception as e:
128
+ exporter.logger.error("%s Failed to save file: %s", TAG, e)
129
+ return