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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/clean.py +97 -78
  3. novel_downloader/cli/config.py +177 -0
  4. novel_downloader/cli/download.py +132 -87
  5. novel_downloader/cli/export.py +77 -0
  6. novel_downloader/cli/main.py +21 -28
  7. novel_downloader/config/__init__.py +1 -25
  8. novel_downloader/config/adapter.py +32 -31
  9. novel_downloader/config/loader.py +3 -3
  10. novel_downloader/config/site_rules.py +1 -2
  11. novel_downloader/core/__init__.py +3 -6
  12. novel_downloader/core/downloaders/__init__.py +10 -13
  13. novel_downloader/core/downloaders/base.py +233 -0
  14. novel_downloader/core/downloaders/biquge.py +27 -0
  15. novel_downloader/core/downloaders/common.py +414 -0
  16. novel_downloader/core/downloaders/esjzone.py +27 -0
  17. novel_downloader/core/downloaders/linovelib.py +27 -0
  18. novel_downloader/core/downloaders/qianbi.py +27 -0
  19. novel_downloader/core/downloaders/qidian.py +352 -0
  20. novel_downloader/core/downloaders/sfacg.py +27 -0
  21. novel_downloader/core/downloaders/yamibo.py +27 -0
  22. novel_downloader/core/exporters/__init__.py +37 -0
  23. novel_downloader/core/{savers → exporters}/base.py +73 -44
  24. novel_downloader/core/exporters/biquge.py +25 -0
  25. novel_downloader/core/exporters/common/__init__.py +12 -0
  26. novel_downloader/core/{savers → exporters}/common/epub.py +40 -52
  27. novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +36 -39
  28. novel_downloader/core/{savers → exporters}/common/txt.py +20 -24
  29. novel_downloader/core/exporters/epub_utils/__init__.py +40 -0
  30. novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -1
  31. novel_downloader/core/exporters/epub_utils/image_loader.py +131 -0
  32. novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -3
  33. novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +49 -2
  34. novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -1
  35. novel_downloader/core/exporters/esjzone.py +25 -0
  36. novel_downloader/core/exporters/linovelib/__init__.py +10 -0
  37. novel_downloader/core/exporters/linovelib/epub.py +449 -0
  38. novel_downloader/core/exporters/linovelib/main_exporter.py +127 -0
  39. novel_downloader/core/exporters/linovelib/txt.py +129 -0
  40. novel_downloader/core/exporters/qianbi.py +25 -0
  41. novel_downloader/core/{savers → exporters}/qidian.py +8 -8
  42. novel_downloader/core/exporters/sfacg.py +25 -0
  43. novel_downloader/core/exporters/yamibo.py +25 -0
  44. novel_downloader/core/factory/__init__.py +5 -17
  45. novel_downloader/core/factory/downloader.py +24 -126
  46. novel_downloader/core/factory/exporter.py +58 -0
  47. novel_downloader/core/factory/fetcher.py +96 -0
  48. novel_downloader/core/factory/parser.py +17 -12
  49. novel_downloader/core/{requesters → fetchers}/__init__.py +22 -15
  50. novel_downloader/core/{requesters → fetchers}/base/__init__.py +2 -4
  51. novel_downloader/core/fetchers/base/browser.py +383 -0
  52. novel_downloader/core/fetchers/base/rate_limiter.py +86 -0
  53. novel_downloader/core/fetchers/base/session.py +419 -0
  54. novel_downloader/core/fetchers/biquge/__init__.py +14 -0
  55. novel_downloader/core/{requesters/biquge/async_session.py → fetchers/biquge/browser.py} +18 -6
  56. novel_downloader/core/{requesters → fetchers}/biquge/session.py +23 -30
  57. novel_downloader/core/fetchers/common/__init__.py +14 -0
  58. novel_downloader/core/fetchers/common/browser.py +79 -0
  59. novel_downloader/core/{requesters/common/async_session.py → fetchers/common/session.py} +8 -25
  60. novel_downloader/core/fetchers/esjzone/__init__.py +14 -0
  61. novel_downloader/core/fetchers/esjzone/browser.py +202 -0
  62. novel_downloader/core/{requesters/esjzone/async_session.py → fetchers/esjzone/session.py} +62 -42
  63. novel_downloader/core/fetchers/linovelib/__init__.py +14 -0
  64. novel_downloader/core/fetchers/linovelib/browser.py +178 -0
  65. novel_downloader/core/fetchers/linovelib/session.py +178 -0
  66. novel_downloader/core/fetchers/qianbi/__init__.py +14 -0
  67. novel_downloader/core/{requesters/qianbi/session.py → fetchers/qianbi/browser.py} +30 -48
  68. novel_downloader/core/{requesters/qianbi/async_session.py → fetchers/qianbi/session.py} +18 -6
  69. novel_downloader/core/fetchers/qidian/__init__.py +14 -0
  70. novel_downloader/core/fetchers/qidian/browser.py +266 -0
  71. novel_downloader/core/fetchers/qidian/session.py +326 -0
  72. novel_downloader/core/fetchers/sfacg/__init__.py +14 -0
  73. novel_downloader/core/fetchers/sfacg/browser.py +189 -0
  74. novel_downloader/core/{requesters/sfacg/async_session.py → fetchers/sfacg/session.py} +43 -73
  75. novel_downloader/core/fetchers/yamibo/__init__.py +14 -0
  76. novel_downloader/core/fetchers/yamibo/browser.py +229 -0
  77. novel_downloader/core/{requesters/yamibo/async_session.py → fetchers/yamibo/session.py} +62 -44
  78. novel_downloader/core/interfaces/__init__.py +8 -12
  79. novel_downloader/core/interfaces/downloader.py +54 -0
  80. novel_downloader/core/interfaces/{saver.py → exporter.py} +12 -12
  81. novel_downloader/core/interfaces/fetcher.py +162 -0
  82. novel_downloader/core/interfaces/parser.py +6 -7
  83. novel_downloader/core/parsers/__init__.py +5 -6
  84. novel_downloader/core/parsers/base.py +9 -13
  85. novel_downloader/core/parsers/biquge/main_parser.py +12 -13
  86. novel_downloader/core/parsers/common/helper.py +3 -3
  87. novel_downloader/core/parsers/common/main_parser.py +39 -34
  88. novel_downloader/core/parsers/esjzone/main_parser.py +24 -17
  89. novel_downloader/core/parsers/linovelib/__init__.py +10 -0
  90. novel_downloader/core/parsers/linovelib/main_parser.py +210 -0
  91. novel_downloader/core/parsers/qianbi/main_parser.py +21 -15
  92. novel_downloader/core/parsers/qidian/__init__.py +2 -11
  93. novel_downloader/core/parsers/qidian/book_info_parser.py +113 -0
  94. novel_downloader/core/parsers/qidian/{browser/chapter_encrypted.py → chapter_encrypted.py} +162 -135
  95. novel_downloader/core/parsers/qidian/chapter_normal.py +150 -0
  96. novel_downloader/core/parsers/qidian/{session/chapter_router.py → chapter_router.py} +15 -15
  97. novel_downloader/core/parsers/qidian/{browser/main_parser.py → main_parser.py} +49 -40
  98. novel_downloader/core/parsers/qidian/utils/__init__.py +27 -0
  99. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +145 -0
  100. novel_downloader/core/parsers/qidian/{shared → utils}/helpers.py +41 -68
  101. novel_downloader/core/parsers/qidian/{session → utils}/node_decryptor.py +64 -50
  102. novel_downloader/core/parsers/sfacg/main_parser.py +12 -12
  103. novel_downloader/core/parsers/yamibo/main_parser.py +10 -10
  104. novel_downloader/locales/en.json +18 -2
  105. novel_downloader/locales/zh.json +18 -2
  106. novel_downloader/models/__init__.py +64 -0
  107. novel_downloader/models/browser.py +21 -0
  108. novel_downloader/models/chapter.py +25 -0
  109. novel_downloader/models/config.py +100 -0
  110. novel_downloader/models/login.py +20 -0
  111. novel_downloader/models/site_rules.py +99 -0
  112. novel_downloader/models/tasks.py +33 -0
  113. novel_downloader/models/types.py +15 -0
  114. novel_downloader/resources/config/settings.toml +31 -25
  115. novel_downloader/resources/json/linovelib_font_map.json +3573 -0
  116. novel_downloader/tui/__init__.py +7 -0
  117. novel_downloader/tui/app.py +32 -0
  118. novel_downloader/tui/main.py +17 -0
  119. novel_downloader/tui/screens/__init__.py +14 -0
  120. novel_downloader/tui/screens/home.py +191 -0
  121. novel_downloader/tui/screens/login.py +74 -0
  122. novel_downloader/tui/styles/home_layout.tcss +79 -0
  123. novel_downloader/tui/widgets/richlog_handler.py +24 -0
  124. novel_downloader/utils/__init__.py +6 -0
  125. novel_downloader/utils/chapter_storage.py +25 -38
  126. novel_downloader/utils/constants.py +15 -5
  127. novel_downloader/utils/cookies.py +66 -0
  128. novel_downloader/utils/crypto_utils.py +1 -74
  129. novel_downloader/utils/file_utils/io.py +1 -1
  130. novel_downloader/utils/fontocr/ocr_v1.py +2 -1
  131. novel_downloader/utils/fontocr/ocr_v2.py +2 -2
  132. novel_downloader/utils/hash_store.py +10 -18
  133. novel_downloader/utils/hash_utils.py +3 -2
  134. novel_downloader/utils/logger.py +2 -3
  135. novel_downloader/utils/network.py +53 -39
  136. novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
  137. novel_downloader/utils/text_utils/font_mapping.py +1 -1
  138. novel_downloader/utils/text_utils/text_cleaning.py +1 -1
  139. novel_downloader/utils/time_utils/datetime_utils.py +3 -3
  140. novel_downloader/utils/time_utils/sleep_utils.py +3 -3
  141. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/METADATA +72 -38
  142. novel_downloader-1.4.0.dist-info/RECORD +170 -0
  143. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
  144. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/entry_points.txt +1 -0
  145. novel_downloader/cli/interactive.py +0 -66
  146. novel_downloader/cli/settings.py +0 -177
  147. novel_downloader/config/models.py +0 -187
  148. novel_downloader/core/downloaders/base/__init__.py +0 -14
  149. novel_downloader/core/downloaders/base/base_async.py +0 -153
  150. novel_downloader/core/downloaders/base/base_sync.py +0 -208
  151. novel_downloader/core/downloaders/biquge/__init__.py +0 -14
  152. novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
  153. novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
  154. novel_downloader/core/downloaders/common/__init__.py +0 -14
  155. novel_downloader/core/downloaders/common/common_async.py +0 -218
  156. novel_downloader/core/downloaders/common/common_sync.py +0 -210
  157. novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
  158. novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
  159. novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
  160. novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
  161. novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
  162. novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
  163. novel_downloader/core/downloaders/qidian/__init__.py +0 -10
  164. novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -227
  165. novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
  166. novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
  167. novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
  168. novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
  169. novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
  170. novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
  171. novel_downloader/core/factory/requester.py +0 -144
  172. novel_downloader/core/factory/saver.py +0 -56
  173. novel_downloader/core/interfaces/async_downloader.py +0 -36
  174. novel_downloader/core/interfaces/async_requester.py +0 -84
  175. novel_downloader/core/interfaces/sync_downloader.py +0 -36
  176. novel_downloader/core/interfaces/sync_requester.py +0 -82
  177. novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
  178. novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
  179. novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
  180. novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
  181. novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
  182. novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
  183. novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
  184. novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
  185. novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
  186. novel_downloader/core/requesters/base/async_session.py +0 -410
  187. novel_downloader/core/requesters/base/browser.py +0 -337
  188. novel_downloader/core/requesters/base/session.py +0 -378
  189. novel_downloader/core/requesters/biquge/__init__.py +0 -14
  190. novel_downloader/core/requesters/common/__init__.py +0 -17
  191. novel_downloader/core/requesters/common/session.py +0 -113
  192. novel_downloader/core/requesters/esjzone/__init__.py +0 -13
  193. novel_downloader/core/requesters/esjzone/session.py +0 -235
  194. novel_downloader/core/requesters/qianbi/__init__.py +0 -13
  195. novel_downloader/core/requesters/qidian/__init__.py +0 -21
  196. novel_downloader/core/requesters/qidian/broswer.py +0 -307
  197. novel_downloader/core/requesters/qidian/session.py +0 -290
  198. novel_downloader/core/requesters/sfacg/__init__.py +0 -13
  199. novel_downloader/core/requesters/sfacg/session.py +0 -242
  200. novel_downloader/core/requesters/yamibo/__init__.py +0 -13
  201. novel_downloader/core/requesters/yamibo/session.py +0 -237
  202. novel_downloader/core/savers/__init__.py +0 -34
  203. novel_downloader/core/savers/biquge.py +0 -25
  204. novel_downloader/core/savers/common/__init__.py +0 -12
  205. novel_downloader/core/savers/epub_utils/__init__.py +0 -26
  206. novel_downloader/core/savers/esjzone.py +0 -25
  207. novel_downloader/core/savers/qianbi.py +0 -25
  208. novel_downloader/core/savers/sfacg.py +0 -25
  209. novel_downloader/core/savers/yamibo.py +0 -25
  210. novel_downloader/resources/config/rules.toml +0 -196
  211. novel_downloader-1.3.2.dist-info/RECORD +0 -165
  212. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/licenses/LICENSE +0 -0
  213. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,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")