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,414 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.downloaders.common
4
+ ----------------------------------------
5
+
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ from collections.abc import Awaitable, Callable
11
+ from contextlib import suppress
12
+ from typing import Any, cast
13
+
14
+ from novel_downloader.core.downloaders.base import BaseDownloader
15
+ from novel_downloader.models import ChapterDict, CidTask, HtmlTask, RestoreTask
16
+ from novel_downloader.utils.chapter_storage import ChapterStorage
17
+ from novel_downloader.utils.file_utils import save_as_json, save_as_txt
18
+ from novel_downloader.utils.time_utils import (
19
+ async_sleep_with_random_delay,
20
+ calculate_time_difference,
21
+ )
22
+
23
+
24
+ class CommonDownloader(BaseDownloader):
25
+ """
26
+ Specialized Async downloader for common novels.
27
+ """
28
+
29
+ async def _download_one(
30
+ self,
31
+ book_id: str,
32
+ *,
33
+ progress_hook: Callable[[int, int], Awaitable[None]] | None = None,
34
+ **kwargs: Any,
35
+ ) -> None:
36
+ """
37
+ The full download logic for a single book.
38
+
39
+ :param book_id: The identifier of the book to download.
40
+ """
41
+ TAG = "[Downloader]"
42
+
43
+ raw_base = self.raw_data_dir / book_id
44
+ cache_base = self.cache_dir / book_id
45
+ info_path = raw_base / "book_info.json"
46
+ chapters_html_dir = cache_base / "html"
47
+
48
+ raw_base.mkdir(parents=True, exist_ok=True)
49
+ if self.save_html:
50
+ chapters_html_dir.mkdir(parents=True, exist_ok=True)
51
+ normal_cs = ChapterStorage(
52
+ raw_base=raw_base,
53
+ namespace="chapters",
54
+ backend_type=self._config.storage_backend,
55
+ batch_size=self._config.storage_batch_size,
56
+ )
57
+
58
+ # load or fetch book_info
59
+ book_info: dict[str, Any]
60
+ re_fetch = True
61
+ old_data: dict[str, Any] = {}
62
+
63
+ if info_path.exists():
64
+ try:
65
+ old_data = json.loads(info_path.read_text("utf-8"))
66
+ days, *_ = calculate_time_difference(
67
+ old_data.get("update_time", ""), "UTC+8"
68
+ )
69
+ re_fetch = days > 1
70
+ except Exception:
71
+ re_fetch = True
72
+
73
+ if re_fetch:
74
+ info_html = await self.fetcher.get_book_info(book_id)
75
+ if self.save_html:
76
+ for i, html in enumerate(info_html):
77
+ save_as_txt(html, chapters_html_dir / f"info_{i}.html")
78
+ book_info = self.parser.parse_book_info(info_html)
79
+
80
+ if book_info.get("book_name") != "未找到书名":
81
+ save_as_json(book_info, info_path)
82
+ else:
83
+ self.logger.warning("%s 书籍信息未找到, book_id = %s", TAG, book_id)
84
+ book_info = old_data or {"book_name": "未找到书名"}
85
+ else:
86
+ book_info = old_data
87
+
88
+ vols = book_info.get("volumes", [])
89
+ total_chapters = 0
90
+ for vol in vols:
91
+ total_chapters += len(vol.get("chapters", []))
92
+ if total_chapters == 0:
93
+ self.logger.warning("%s 书籍没有章节可下载: book_id=%s", TAG, book_id)
94
+ return
95
+
96
+ completed_count = 0
97
+
98
+ # setup queue, semaphore
99
+ semaphore = asyncio.Semaphore(self.download_workers)
100
+ cid_queue: asyncio.Queue[CidTask] = asyncio.Queue()
101
+ restore_queue: asyncio.Queue[RestoreTask] = asyncio.Queue()
102
+ html_queue: asyncio.Queue[HtmlTask] = asyncio.Queue()
103
+ save_queue: asyncio.Queue[ChapterDict] = asyncio.Queue()
104
+ pending_restore: dict[str, RestoreTask] = {}
105
+
106
+ def update_book_info(
107
+ vol_idx: int,
108
+ chap_idx: int,
109
+ cid: str,
110
+ ) -> None:
111
+ try:
112
+ book_info["volumes"][vol_idx]["chapters"][chap_idx]["chapterId"] = cid
113
+ except (IndexError, KeyError, TypeError) as e:
114
+ self.logger.info(
115
+ "[update_book_info] Failed to update vol=%s, chap=%s: %s",
116
+ vol_idx,
117
+ chap_idx,
118
+ e,
119
+ )
120
+
121
+ async def fetcher_worker(
122
+ book_id: str,
123
+ cid_queue: asyncio.Queue[CidTask],
124
+ html_queue: asyncio.Queue[HtmlTask],
125
+ restore_queue: asyncio.Queue[RestoreTask],
126
+ retry_times: int,
127
+ semaphore: asyncio.Semaphore,
128
+ ) -> None:
129
+ while True:
130
+ task = await cid_queue.get()
131
+ cid = task.cid
132
+ if not cid and task.prev_cid:
133
+ await restore_queue.put(
134
+ RestoreTask(
135
+ vol_idx=task.vol_idx,
136
+ chap_idx=task.chap_idx,
137
+ prev_cid=task.prev_cid,
138
+ )
139
+ )
140
+ cid_queue.task_done()
141
+ continue
142
+
143
+ if not cid:
144
+ self.logger.warning("[Fetcher] Skipped empty cid task: %s", task)
145
+ cid_queue.task_done()
146
+ continue
147
+
148
+ try:
149
+ async with semaphore:
150
+ html_list = await self.fetcher.get_book_chapter(book_id, cid)
151
+ await html_queue.put(
152
+ HtmlTask(
153
+ cid=cid,
154
+ retry=task.retry,
155
+ html_list=html_list,
156
+ vol_idx=task.vol_idx,
157
+ chap_idx=task.chap_idx,
158
+ )
159
+ )
160
+ self.logger.info("[Fetcher] Downloaded chapter %s", cid)
161
+ await async_sleep_with_random_delay(
162
+ self.request_interval,
163
+ mul_spread=1.1,
164
+ max_sleep=self.request_interval + 2,
165
+ )
166
+
167
+ except Exception as e:
168
+ if task.retry < retry_times:
169
+ await cid_queue.put(
170
+ CidTask(
171
+ prev_cid=task.prev_cid,
172
+ cid=cid,
173
+ retry=task.retry + 1,
174
+ vol_idx=task.vol_idx,
175
+ chap_idx=task.chap_idx,
176
+ )
177
+ )
178
+ self.logger.info(
179
+ "[Fetcher] Re-queued chapter %s for retry #%d: %s",
180
+ cid,
181
+ task.retry + 1,
182
+ e,
183
+ )
184
+ backoff = self.backoff_factor * (2**task.retry)
185
+ await async_sleep_with_random_delay(
186
+ base=backoff,
187
+ mul_spread=1.2,
188
+ max_sleep=backoff + 3,
189
+ )
190
+ else:
191
+ self.logger.warning(
192
+ "[Fetcher] Max retries reached for chapter %s: %s",
193
+ cid,
194
+ e,
195
+ )
196
+
197
+ finally:
198
+ cid_queue.task_done()
199
+
200
+ async def parser_worker(
201
+ worker_id: int,
202
+ cid_queue: asyncio.Queue[CidTask],
203
+ html_queue: asyncio.Queue[HtmlTask],
204
+ save_queue: asyncio.Queue[ChapterDict],
205
+ retry_times: int,
206
+ ) -> None:
207
+ while True:
208
+ task = await html_queue.get()
209
+ try:
210
+ chap_json = await asyncio.to_thread(
211
+ self.parser.parse_chapter,
212
+ task.html_list,
213
+ task.cid,
214
+ )
215
+ if chap_json:
216
+ await save_queue.put(chap_json)
217
+ self.logger.info(
218
+ "[Parser-%d] saved chapter %s",
219
+ worker_id,
220
+ task.cid,
221
+ )
222
+ else:
223
+ raise ValueError("Empty parse result")
224
+ except Exception as e:
225
+ if task.retry < retry_times:
226
+ await cid_queue.put(
227
+ CidTask(
228
+ prev_cid=None,
229
+ cid=task.cid,
230
+ retry=task.retry + 1,
231
+ vol_idx=task.vol_idx,
232
+ chap_idx=task.chap_idx,
233
+ )
234
+ )
235
+ self.logger.info(
236
+ "[Parser-%d] Re-queued cid %s for retry #%d: %s",
237
+ worker_id,
238
+ task.cid,
239
+ task.retry + 1,
240
+ e,
241
+ )
242
+ else:
243
+ self.logger.warning(
244
+ "[Parser-%d] Max retries reached for cid %s: %s",
245
+ worker_id,
246
+ task.cid,
247
+ e,
248
+ )
249
+ finally:
250
+ html_queue.task_done()
251
+
252
+ async def storage_worker(
253
+ cs: ChapterStorage,
254
+ save_queue: asyncio.Queue[ChapterDict],
255
+ restore_queue: asyncio.Queue[RestoreTask],
256
+ cid_queue: asyncio.Queue[CidTask],
257
+ ) -> None:
258
+ nonlocal completed_count
259
+ while True:
260
+ save_task = asyncio.create_task(save_queue.get())
261
+ restore_task = asyncio.create_task(restore_queue.get())
262
+
263
+ done, pending = await asyncio.wait(
264
+ [save_task, restore_task],
265
+ return_when=asyncio.FIRST_COMPLETED,
266
+ )
267
+
268
+ for task in pending:
269
+ task.cancel()
270
+ with suppress(asyncio.CancelledError):
271
+ await task
272
+
273
+ for task in done:
274
+ item = task.result()
275
+
276
+ if isinstance(item, dict): # from save_queue
277
+ try:
278
+ cs.save(cast(ChapterDict, item))
279
+ completed_count += 1
280
+ if progress_hook:
281
+ await progress_hook(completed_count, total_chapters)
282
+
283
+ curr_cid = item["id"]
284
+ if curr_cid in pending_restore:
285
+ rt = pending_restore.pop(curr_cid)
286
+ next_cid = item.get("extra", {}).get("next_chapter_id")
287
+ if next_cid:
288
+ update_book_info(
289
+ vol_idx=rt.vol_idx,
290
+ chap_idx=rt.chap_idx,
291
+ cid=next_cid,
292
+ )
293
+ await cid_queue.put(
294
+ CidTask(
295
+ prev_cid=rt.prev_cid,
296
+ cid=next_cid,
297
+ vol_idx=rt.vol_idx,
298
+ chap_idx=rt.chap_idx,
299
+ )
300
+ )
301
+ else:
302
+ self.logger.warning(
303
+ "[storage_worker] No next_cid found for %r",
304
+ rt,
305
+ )
306
+ except Exception as e:
307
+ self.logger.error("[storage_worker] Failed to save: %s", e)
308
+ finally:
309
+ save_queue.task_done()
310
+
311
+ elif isinstance(item, RestoreTask): # from restore_queue
312
+ prev_json = cs.get(item.prev_cid)
313
+ next_cid = (
314
+ prev_json.get("extra", {}).get("next_chapter_id")
315
+ if prev_json
316
+ else None
317
+ )
318
+ if next_cid:
319
+ update_book_info(
320
+ vol_idx=item.vol_idx,
321
+ chap_idx=item.chap_idx,
322
+ cid=next_cid,
323
+ )
324
+ await cid_queue.put(
325
+ CidTask(
326
+ prev_cid=item.prev_cid,
327
+ cid=next_cid,
328
+ vol_idx=item.vol_idx,
329
+ chap_idx=item.chap_idx,
330
+ )
331
+ )
332
+ else:
333
+ pending_restore[item.prev_cid] = item
334
+ restore_queue.task_done()
335
+
336
+ fetcher_tasks = [
337
+ asyncio.create_task(
338
+ fetcher_worker(
339
+ book_id,
340
+ cid_queue,
341
+ html_queue,
342
+ restore_queue,
343
+ self.retry_times,
344
+ semaphore,
345
+ )
346
+ )
347
+ for _ in range(self.download_workers)
348
+ ]
349
+
350
+ parser_tasks = [
351
+ asyncio.create_task(
352
+ parser_worker(
353
+ i,
354
+ cid_queue,
355
+ html_queue,
356
+ save_queue,
357
+ self.retry_times,
358
+ )
359
+ )
360
+ for i in range(self.parser_workers)
361
+ ]
362
+
363
+ storage_task = asyncio.create_task(
364
+ storage_worker(
365
+ cs=normal_cs,
366
+ save_queue=save_queue,
367
+ restore_queue=restore_queue,
368
+ cid_queue=cid_queue,
369
+ )
370
+ )
371
+
372
+ last_cid: str | None = None
373
+ for vol_idx, vol in enumerate(vols):
374
+ chapters = vol.get("chapters", [])
375
+ for chap_idx, chap in enumerate(chapters):
376
+ cid = chap.get("chapterId")
377
+ if cid and normal_cs.exists(cid) and self.skip_existing:
378
+ completed_count += 1
379
+ if progress_hook:
380
+ await progress_hook(completed_count, total_chapters)
381
+ last_cid = cid
382
+ continue
383
+
384
+ await cid_queue.put(
385
+ CidTask(
386
+ vol_idx=vol_idx,
387
+ chap_idx=chap_idx,
388
+ cid=cid,
389
+ prev_cid=last_cid,
390
+ )
391
+ )
392
+ last_cid = cid
393
+
394
+ await restore_queue.join()
395
+ await cid_queue.join()
396
+ await html_queue.join()
397
+ await save_queue.join()
398
+
399
+ for task in fetcher_tasks + parser_tasks + [storage_task]:
400
+ task.cancel()
401
+ with suppress(asyncio.CancelledError):
402
+ await task
403
+
404
+ normal_cs.close()
405
+ save_as_json(book_info, info_path)
406
+
407
+ await asyncio.to_thread(self.exporter.export, book_id)
408
+
409
+ self.logger.info(
410
+ "%s Novel '%s' download completed.",
411
+ TAG,
412
+ book_info.get("book_name", "unknown"),
413
+ )
414
+ return
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.downloaders.esjzone
4
+ -----------------------------------------
5
+
6
+ """
7
+
8
+ from novel_downloader.core.downloaders.common import CommonDownloader
9
+ from novel_downloader.core.interfaces import (
10
+ ExporterProtocol,
11
+ FetcherProtocol,
12
+ ParserProtocol,
13
+ )
14
+ from novel_downloader.models import DownloaderConfig
15
+
16
+
17
+ class EsjzoneDownloader(CommonDownloader):
18
+ """"""
19
+
20
+ def __init__(
21
+ self,
22
+ fetcher: FetcherProtocol,
23
+ parser: ParserProtocol,
24
+ exporter: ExporterProtocol,
25
+ config: DownloaderConfig,
26
+ ):
27
+ super().__init__(fetcher, parser, exporter, config, "esjzone")
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.downloaders.linovelib
4
+ -------------------------------------------
5
+
6
+ """
7
+
8
+ from novel_downloader.core.downloaders.common import CommonDownloader
9
+ from novel_downloader.core.interfaces import (
10
+ ExporterProtocol,
11
+ FetcherProtocol,
12
+ ParserProtocol,
13
+ )
14
+ from novel_downloader.models import DownloaderConfig
15
+
16
+
17
+ class LinovelibDownloader(CommonDownloader):
18
+ """"""
19
+
20
+ def __init__(
21
+ self,
22
+ fetcher: FetcherProtocol,
23
+ parser: ParserProtocol,
24
+ exporter: ExporterProtocol,
25
+ config: DownloaderConfig,
26
+ ):
27
+ super().__init__(fetcher, parser, exporter, config, "linovelib")
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.downloaders.qianbi
4
+ ----------------------------------------
5
+
6
+ """
7
+
8
+ from novel_downloader.core.downloaders.common import CommonDownloader
9
+ from novel_downloader.core.interfaces import (
10
+ ExporterProtocol,
11
+ FetcherProtocol,
12
+ ParserProtocol,
13
+ )
14
+ from novel_downloader.models import DownloaderConfig
15
+
16
+
17
+ class QianbiDownloader(CommonDownloader):
18
+ """"""
19
+
20
+ def __init__(
21
+ self,
22
+ fetcher: FetcherProtocol,
23
+ parser: ParserProtocol,
24
+ exporter: ExporterProtocol,
25
+ config: DownloaderConfig,
26
+ ):
27
+ super().__init__(fetcher, parser, exporter, config, "qianbi")