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.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/clean.py +97 -78
- novel_downloader/cli/config.py +177 -0
- novel_downloader/cli/download.py +132 -87
- novel_downloader/cli/export.py +77 -0
- novel_downloader/cli/main.py +21 -28
- novel_downloader/config/__init__.py +1 -25
- novel_downloader/config/adapter.py +32 -31
- novel_downloader/config/loader.py +3 -3
- novel_downloader/config/site_rules.py +1 -2
- novel_downloader/core/__init__.py +3 -6
- novel_downloader/core/downloaders/__init__.py +10 -13
- novel_downloader/core/downloaders/base.py +233 -0
- novel_downloader/core/downloaders/biquge.py +27 -0
- novel_downloader/core/downloaders/common.py +414 -0
- novel_downloader/core/downloaders/esjzone.py +27 -0
- novel_downloader/core/downloaders/linovelib.py +27 -0
- novel_downloader/core/downloaders/qianbi.py +27 -0
- novel_downloader/core/downloaders/qidian.py +352 -0
- novel_downloader/core/downloaders/sfacg.py +27 -0
- novel_downloader/core/downloaders/yamibo.py +27 -0
- novel_downloader/core/exporters/__init__.py +37 -0
- novel_downloader/core/{savers → exporters}/base.py +73 -39
- novel_downloader/core/exporters/biquge.py +25 -0
- novel_downloader/core/exporters/common/__init__.py +12 -0
- novel_downloader/core/{savers → exporters}/common/epub.py +22 -22
- novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +35 -40
- novel_downloader/core/{savers → exporters}/common/txt.py +20 -23
- novel_downloader/core/{savers → exporters}/epub_utils/__init__.py +8 -3
- novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -2
- novel_downloader/core/{savers → exporters}/epub_utils/image_loader.py +46 -4
- novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -4
- novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +3 -3
- novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -2
- novel_downloader/core/exporters/esjzone.py +25 -0
- novel_downloader/core/exporters/linovelib/__init__.py +10 -0
- novel_downloader/core/exporters/linovelib/epub.py +449 -0
- novel_downloader/core/exporters/linovelib/main_exporter.py +127 -0
- novel_downloader/core/exporters/linovelib/txt.py +129 -0
- novel_downloader/core/exporters/qianbi.py +25 -0
- novel_downloader/core/{savers → exporters}/qidian.py +8 -8
- novel_downloader/core/exporters/sfacg.py +25 -0
- novel_downloader/core/exporters/yamibo.py +25 -0
- novel_downloader/core/factory/__init__.py +5 -17
- novel_downloader/core/factory/downloader.py +24 -126
- novel_downloader/core/factory/exporter.py +58 -0
- novel_downloader/core/factory/fetcher.py +96 -0
- novel_downloader/core/factory/parser.py +17 -12
- novel_downloader/core/{requesters → fetchers}/__init__.py +22 -15
- novel_downloader/core/{requesters → fetchers}/base/__init__.py +2 -4
- novel_downloader/core/fetchers/base/browser.py +383 -0
- novel_downloader/core/fetchers/base/rate_limiter.py +86 -0
- novel_downloader/core/fetchers/base/session.py +419 -0
- novel_downloader/core/fetchers/biquge/__init__.py +14 -0
- novel_downloader/core/{requesters/biquge/async_session.py → fetchers/biquge/browser.py} +18 -6
- novel_downloader/core/{requesters → fetchers}/biquge/session.py +23 -30
- novel_downloader/core/fetchers/common/__init__.py +14 -0
- novel_downloader/core/fetchers/common/browser.py +79 -0
- novel_downloader/core/{requesters/common/async_session.py → fetchers/common/session.py} +8 -25
- novel_downloader/core/fetchers/esjzone/__init__.py +14 -0
- novel_downloader/core/fetchers/esjzone/browser.py +202 -0
- novel_downloader/core/{requesters/esjzone/async_session.py → fetchers/esjzone/session.py} +62 -42
- novel_downloader/core/fetchers/linovelib/__init__.py +14 -0
- novel_downloader/core/fetchers/linovelib/browser.py +178 -0
- novel_downloader/core/fetchers/linovelib/session.py +178 -0
- novel_downloader/core/fetchers/qianbi/__init__.py +14 -0
- novel_downloader/core/{requesters/qianbi/session.py → fetchers/qianbi/browser.py} +30 -48
- novel_downloader/core/{requesters/qianbi/async_session.py → fetchers/qianbi/session.py} +18 -6
- novel_downloader/core/fetchers/qidian/__init__.py +14 -0
- novel_downloader/core/fetchers/qidian/browser.py +266 -0
- novel_downloader/core/fetchers/qidian/session.py +326 -0
- novel_downloader/core/fetchers/sfacg/__init__.py +14 -0
- novel_downloader/core/fetchers/sfacg/browser.py +189 -0
- novel_downloader/core/{requesters/sfacg/async_session.py → fetchers/sfacg/session.py} +43 -73
- novel_downloader/core/fetchers/yamibo/__init__.py +14 -0
- novel_downloader/core/fetchers/yamibo/browser.py +229 -0
- novel_downloader/core/{requesters/yamibo/async_session.py → fetchers/yamibo/session.py} +62 -44
- novel_downloader/core/interfaces/__init__.py +8 -12
- novel_downloader/core/interfaces/downloader.py +54 -0
- novel_downloader/core/interfaces/{saver.py → exporter.py} +12 -12
- novel_downloader/core/interfaces/fetcher.py +162 -0
- novel_downloader/core/interfaces/parser.py +6 -7
- novel_downloader/core/parsers/__init__.py +5 -6
- novel_downloader/core/parsers/base.py +9 -13
- novel_downloader/core/parsers/biquge/main_parser.py +12 -13
- novel_downloader/core/parsers/common/helper.py +3 -3
- novel_downloader/core/parsers/common/main_parser.py +39 -34
- novel_downloader/core/parsers/esjzone/main_parser.py +20 -14
- novel_downloader/core/parsers/linovelib/__init__.py +10 -0
- novel_downloader/core/parsers/linovelib/main_parser.py +210 -0
- novel_downloader/core/parsers/qianbi/main_parser.py +21 -15
- novel_downloader/core/parsers/qidian/__init__.py +2 -11
- novel_downloader/core/parsers/qidian/book_info_parser.py +113 -0
- novel_downloader/core/parsers/qidian/{browser/chapter_encrypted.py → chapter_encrypted.py} +162 -135
- novel_downloader/core/parsers/qidian/chapter_normal.py +150 -0
- novel_downloader/core/parsers/qidian/{session/chapter_router.py → chapter_router.py} +15 -15
- novel_downloader/core/parsers/qidian/{browser/main_parser.py → main_parser.py} +49 -40
- novel_downloader/core/parsers/qidian/utils/__init__.py +27 -0
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +145 -0
- novel_downloader/core/parsers/qidian/{shared → utils}/helpers.py +41 -68
- novel_downloader/core/parsers/qidian/{session → utils}/node_decryptor.py +64 -50
- novel_downloader/core/parsers/sfacg/main_parser.py +12 -12
- novel_downloader/core/parsers/yamibo/main_parser.py +10 -10
- novel_downloader/locales/en.json +18 -2
- novel_downloader/locales/zh.json +18 -2
- novel_downloader/models/__init__.py +64 -0
- novel_downloader/models/browser.py +21 -0
- novel_downloader/models/chapter.py +25 -0
- novel_downloader/models/config.py +100 -0
- novel_downloader/models/login.py +20 -0
- novel_downloader/models/site_rules.py +99 -0
- novel_downloader/models/tasks.py +33 -0
- novel_downloader/models/types.py +15 -0
- novel_downloader/resources/config/settings.toml +31 -25
- novel_downloader/resources/json/linovelib_font_map.json +3573 -0
- novel_downloader/tui/__init__.py +7 -0
- novel_downloader/tui/app.py +32 -0
- novel_downloader/tui/main.py +17 -0
- novel_downloader/tui/screens/__init__.py +14 -0
- novel_downloader/tui/screens/home.py +191 -0
- novel_downloader/tui/screens/login.py +74 -0
- novel_downloader/tui/styles/home_layout.tcss +79 -0
- novel_downloader/tui/widgets/richlog_handler.py +24 -0
- novel_downloader/utils/__init__.py +6 -0
- novel_downloader/utils/chapter_storage.py +25 -38
- novel_downloader/utils/constants.py +11 -5
- novel_downloader/utils/cookies.py +66 -0
- novel_downloader/utils/crypto_utils.py +1 -74
- novel_downloader/utils/fontocr/ocr_v1.py +2 -1
- novel_downloader/utils/fontocr/ocr_v2.py +2 -2
- novel_downloader/utils/hash_store.py +10 -18
- novel_downloader/utils/hash_utils.py +3 -2
- novel_downloader/utils/logger.py +2 -3
- novel_downloader/utils/network.py +2 -1
- novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
- novel_downloader/utils/text_utils/font_mapping.py +1 -1
- novel_downloader/utils/text_utils/text_cleaning.py +1 -1
- novel_downloader/utils/time_utils/datetime_utils.py +3 -3
- novel_downloader/utils/time_utils/sleep_utils.py +1 -1
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/METADATA +69 -35
- novel_downloader-1.4.0.dist-info/RECORD +170 -0
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/entry_points.txt +1 -0
- novel_downloader/cli/interactive.py +0 -66
- novel_downloader/cli/settings.py +0 -177
- novel_downloader/config/models.py +0 -187
- novel_downloader/core/downloaders/base/__init__.py +0 -14
- novel_downloader/core/downloaders/base/base_async.py +0 -153
- novel_downloader/core/downloaders/base/base_sync.py +0 -208
- novel_downloader/core/downloaders/biquge/__init__.py +0 -14
- novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
- novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
- novel_downloader/core/downloaders/common/__init__.py +0 -14
- novel_downloader/core/downloaders/common/common_async.py +0 -210
- novel_downloader/core/downloaders/common/common_sync.py +0 -202
- novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
- novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
- novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
- novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
- novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
- novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
- novel_downloader/core/downloaders/qidian/__init__.py +0 -10
- novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -219
- novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
- novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
- novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
- novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
- novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
- novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
- novel_downloader/core/factory/requester.py +0 -144
- novel_downloader/core/factory/saver.py +0 -56
- novel_downloader/core/interfaces/async_downloader.py +0 -36
- novel_downloader/core/interfaces/async_requester.py +0 -84
- novel_downloader/core/interfaces/sync_downloader.py +0 -36
- novel_downloader/core/interfaces/sync_requester.py +0 -82
- novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
- novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
- novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
- novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
- novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
- novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
- novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
- novel_downloader/core/requesters/base/async_session.py +0 -410
- novel_downloader/core/requesters/base/browser.py +0 -337
- novel_downloader/core/requesters/base/session.py +0 -378
- novel_downloader/core/requesters/biquge/__init__.py +0 -14
- novel_downloader/core/requesters/common/__init__.py +0 -17
- novel_downloader/core/requesters/common/session.py +0 -113
- novel_downloader/core/requesters/esjzone/__init__.py +0 -13
- novel_downloader/core/requesters/esjzone/session.py +0 -235
- novel_downloader/core/requesters/qianbi/__init__.py +0 -13
- novel_downloader/core/requesters/qidian/__init__.py +0 -21
- novel_downloader/core/requesters/qidian/broswer.py +0 -307
- novel_downloader/core/requesters/qidian/session.py +0 -290
- novel_downloader/core/requesters/sfacg/__init__.py +0 -13
- novel_downloader/core/requesters/sfacg/session.py +0 -242
- novel_downloader/core/requesters/yamibo/__init__.py +0 -13
- novel_downloader/core/requesters/yamibo/session.py +0 -237
- novel_downloader/core/savers/__init__.py +0 -34
- novel_downloader/core/savers/biquge.py +0 -25
- novel_downloader/core/savers/common/__init__.py +0 -12
- novel_downloader/core/savers/esjzone.py +0 -25
- novel_downloader/core/savers/qianbi.py +0 -25
- novel_downloader/core/savers/sfacg.py +0 -25
- novel_downloader/core/savers/yamibo.py +0 -25
- novel_downloader/resources/config/rules.toml +0 -196
- novel_downloader-1.3.3.dist-info/RECORD +0 -166
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {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")
|