novel-downloader 1.2.1__py3-none-any.whl → 1.3.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 (129) hide show
  1. novel_downloader/__init__.py +1 -2
  2. novel_downloader/cli/__init__.py +0 -1
  3. novel_downloader/cli/clean.py +2 -10
  4. novel_downloader/cli/download.py +18 -22
  5. novel_downloader/cli/interactive.py +0 -1
  6. novel_downloader/cli/main.py +1 -3
  7. novel_downloader/cli/settings.py +8 -8
  8. novel_downloader/config/__init__.py +0 -1
  9. novel_downloader/config/adapter.py +48 -18
  10. novel_downloader/config/loader.py +116 -108
  11. novel_downloader/config/models.py +41 -32
  12. novel_downloader/config/site_rules.py +2 -4
  13. novel_downloader/core/__init__.py +0 -1
  14. novel_downloader/core/downloaders/__init__.py +4 -4
  15. novel_downloader/core/downloaders/base/__init__.py +14 -0
  16. novel_downloader/core/downloaders/{base_async_downloader.py → base/base_async.py} +49 -53
  17. novel_downloader/core/downloaders/{base_downloader.py → base/base_sync.py} +64 -43
  18. novel_downloader/core/downloaders/biquge/__init__.py +12 -0
  19. novel_downloader/core/downloaders/biquge/biquge_sync.py +25 -0
  20. novel_downloader/core/downloaders/common/__init__.py +14 -0
  21. novel_downloader/core/downloaders/{common_asynb_downloader.py → common/common_async.py} +42 -33
  22. novel_downloader/core/downloaders/{common_downloader.py → common/common_sync.py} +34 -23
  23. novel_downloader/core/downloaders/qidian/__init__.py +10 -0
  24. novel_downloader/core/downloaders/{qidian_downloader.py → qidian/qidian_sync.py} +80 -64
  25. novel_downloader/core/factory/__init__.py +4 -5
  26. novel_downloader/core/factory/{downloader_factory.py → downloader.py} +36 -35
  27. novel_downloader/core/factory/{parser_factory.py → parser.py} +12 -14
  28. novel_downloader/core/factory/{requester_factory.py → requester.py} +29 -16
  29. novel_downloader/core/factory/{saver_factory.py → saver.py} +4 -9
  30. novel_downloader/core/interfaces/__init__.py +8 -9
  31. novel_downloader/core/interfaces/{async_downloader_protocol.py → async_downloader.py} +4 -5
  32. novel_downloader/core/interfaces/{async_requester_protocol.py → async_requester.py} +26 -12
  33. novel_downloader/core/interfaces/{parser_protocol.py → parser.py} +11 -6
  34. novel_downloader/core/interfaces/{saver_protocol.py → saver.py} +2 -3
  35. novel_downloader/core/interfaces/{downloader_protocol.py → sync_downloader.py} +6 -7
  36. novel_downloader/core/interfaces/{requester_protocol.py → sync_requester.py} +34 -17
  37. novel_downloader/core/parsers/__init__.py +5 -4
  38. novel_downloader/core/parsers/{base_parser.py → base.py} +20 -11
  39. novel_downloader/core/parsers/biquge/__init__.py +10 -0
  40. novel_downloader/core/parsers/biquge/main_parser.py +126 -0
  41. novel_downloader/core/parsers/{common_parser → common}/__init__.py +2 -3
  42. novel_downloader/core/parsers/{common_parser → common}/helper.py +20 -18
  43. novel_downloader/core/parsers/{common_parser → common}/main_parser.py +15 -9
  44. novel_downloader/core/parsers/{qidian_parser → qidian}/__init__.py +2 -3
  45. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/__init__.py +2 -3
  46. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_encrypted.py +41 -49
  47. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_normal.py +17 -21
  48. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_router.py +10 -9
  49. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/main_parser.py +16 -12
  50. novel_downloader/core/parsers/{qidian_parser → qidian}/session/__init__.py +2 -3
  51. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_encrypted.py +37 -45
  52. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_normal.py +19 -23
  53. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_router.py +10 -9
  54. novel_downloader/core/parsers/{qidian_parser → qidian}/session/main_parser.py +16 -12
  55. novel_downloader/core/parsers/{qidian_parser → qidian}/session/node_decryptor.py +7 -10
  56. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/__init__.py +2 -3
  57. novel_downloader/core/parsers/qidian/shared/book_info_parser.py +150 -0
  58. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/helpers.py +9 -10
  59. novel_downloader/core/requesters/__init__.py +9 -5
  60. novel_downloader/core/requesters/base/__init__.py +16 -0
  61. novel_downloader/core/requesters/{base_async_session.py → base/async_session.py} +180 -73
  62. novel_downloader/core/requesters/base/browser.py +340 -0
  63. novel_downloader/core/requesters/base/session.py +364 -0
  64. novel_downloader/core/requesters/biquge/__init__.py +12 -0
  65. novel_downloader/core/requesters/biquge/session.py +90 -0
  66. novel_downloader/core/requesters/{common_requester → common}/__init__.py +4 -5
  67. novel_downloader/core/requesters/common/async_session.py +96 -0
  68. novel_downloader/core/requesters/common/session.py +113 -0
  69. novel_downloader/core/requesters/qidian/__init__.py +21 -0
  70. novel_downloader/core/requesters/qidian/broswer.py +306 -0
  71. novel_downloader/core/requesters/qidian/session.py +287 -0
  72. novel_downloader/core/savers/__init__.py +5 -3
  73. novel_downloader/core/savers/{base_saver.py → base.py} +12 -13
  74. novel_downloader/core/savers/biquge.py +25 -0
  75. novel_downloader/core/savers/{common_saver → common}/__init__.py +2 -3
  76. novel_downloader/core/savers/{common_saver/common_epub.py → common/epub.py} +24 -52
  77. novel_downloader/core/savers/{common_saver → common}/main_saver.py +43 -9
  78. novel_downloader/core/savers/{common_saver/common_txt.py → common/txt.py} +16 -46
  79. novel_downloader/core/savers/epub_utils/__init__.py +0 -1
  80. novel_downloader/core/savers/epub_utils/css_builder.py +13 -7
  81. novel_downloader/core/savers/epub_utils/initializer.py +4 -5
  82. novel_downloader/core/savers/epub_utils/text_to_html.py +2 -3
  83. novel_downloader/core/savers/epub_utils/volume_intro.py +1 -3
  84. novel_downloader/core/savers/{qidian_saver.py → qidian.py} +12 -6
  85. novel_downloader/locales/en.json +12 -4
  86. novel_downloader/locales/zh.json +9 -1
  87. novel_downloader/resources/config/settings.toml +88 -0
  88. novel_downloader/utils/cache.py +2 -2
  89. novel_downloader/utils/chapter_storage.py +340 -0
  90. novel_downloader/utils/constants.py +8 -5
  91. novel_downloader/utils/crypto_utils.py +3 -3
  92. novel_downloader/utils/file_utils/__init__.py +0 -1
  93. novel_downloader/utils/file_utils/io.py +12 -17
  94. novel_downloader/utils/file_utils/normalize.py +1 -3
  95. novel_downloader/utils/file_utils/sanitize.py +2 -9
  96. novel_downloader/utils/fontocr/__init__.py +0 -1
  97. novel_downloader/utils/fontocr/ocr_v1.py +19 -22
  98. novel_downloader/utils/fontocr/ocr_v2.py +147 -60
  99. novel_downloader/utils/hash_store.py +19 -20
  100. novel_downloader/utils/hash_utils.py +0 -1
  101. novel_downloader/utils/i18n.py +3 -4
  102. novel_downloader/utils/logger.py +5 -6
  103. novel_downloader/utils/model_loader.py +5 -8
  104. novel_downloader/utils/network.py +9 -10
  105. novel_downloader/utils/state.py +6 -7
  106. novel_downloader/utils/text_utils/__init__.py +0 -1
  107. novel_downloader/utils/text_utils/chapter_formatting.py +2 -7
  108. novel_downloader/utils/text_utils/diff_display.py +0 -1
  109. novel_downloader/utils/text_utils/font_mapping.py +1 -4
  110. novel_downloader/utils/text_utils/text_cleaning.py +0 -1
  111. novel_downloader/utils/time_utils/__init__.py +0 -1
  112. novel_downloader/utils/time_utils/datetime_utils.py +9 -11
  113. novel_downloader/utils/time_utils/sleep_utils.py +27 -13
  114. {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/METADATA +14 -17
  115. novel_downloader-1.3.0.dist-info/RECORD +127 -0
  116. {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/WHEEL +1 -1
  117. novel_downloader/core/parsers/qidian_parser/shared/book_info_parser.py +0 -95
  118. novel_downloader/core/requesters/base_browser.py +0 -210
  119. novel_downloader/core/requesters/base_session.py +0 -243
  120. novel_downloader/core/requesters/common_requester/common_async_session.py +0 -98
  121. novel_downloader/core/requesters/common_requester/common_session.py +0 -126
  122. novel_downloader/core/requesters/qidian_requester/__init__.py +0 -22
  123. novel_downloader/core/requesters/qidian_requester/qidian_broswer.py +0 -377
  124. novel_downloader/core/requesters/qidian_requester/qidian_session.py +0 -202
  125. novel_downloader/resources/config/settings.yaml +0 -76
  126. novel_downloader-1.2.1.dist-info/RECORD +0 -115
  127. {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/entry_points.txt +0 -0
  128. {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/licenses/LICENSE +0 -0
  129. {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/top_level.txt +0 -0
@@ -1,31 +1,30 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
- novel_downloader.core.downloaders.qidian_downloader
5
- ---------------------------------------------------
3
+ novel_downloader.core.downloaders.qidian.qidian_sync
4
+ ----------------------------------------------------
6
5
 
7
6
  This module defines `QidianDownloader`, a platform-specific downloader
8
7
  implementation for retrieving novels from Qidian (起点中文网).
9
8
  """
10
9
 
11
10
  import json
12
- import logging
13
- from typing import Any, Dict
11
+ from typing import Any
14
12
 
15
13
  from novel_downloader.config import DownloaderConfig
14
+ from novel_downloader.core.downloaders.base import BaseDownloader
16
15
  from novel_downloader.core.interfaces import (
17
16
  ParserProtocol,
18
- RequesterProtocol,
19
17
  SaverProtocol,
18
+ SyncRequesterProtocol,
20
19
  )
20
+ from novel_downloader.utils.chapter_storage import ChapterStorage
21
21
  from novel_downloader.utils.file_utils import save_as_json, save_as_txt
22
22
  from novel_downloader.utils.network import download_image_as_bytes
23
23
  from novel_downloader.utils.state import state_mgr
24
- from novel_downloader.utils.time_utils import calculate_time_difference
25
-
26
- from .base_downloader import BaseDownloader
27
-
28
- logger = logging.getLogger(__name__)
24
+ from novel_downloader.utils.time_utils import (
25
+ calculate_time_difference,
26
+ sleep_with_random_delay,
27
+ )
29
28
 
30
29
 
31
30
  class QidianDownloader(BaseDownloader):
@@ -35,36 +34,17 @@ class QidianDownloader(BaseDownloader):
35
34
 
36
35
  def __init__(
37
36
  self,
38
- requester: RequesterProtocol,
37
+ requester: SyncRequesterProtocol,
39
38
  parser: ParserProtocol,
40
39
  saver: SaverProtocol,
41
40
  config: DownloaderConfig,
42
41
  ):
43
- super().__init__(requester, parser, saver, config)
42
+ super().__init__(requester, parser, saver, config, "qidian")
44
43
 
45
44
  self._site_key = "qidian"
46
45
  self._is_logged_in = self._handle_login()
47
46
  state_mgr.set_manual_login_flag(self._site_key, not self._is_logged_in)
48
47
 
49
- def _handle_login(self) -> bool:
50
- """
51
- Perform login with automatic fallback to manual:
52
-
53
- 1. If manual_flag is False, try automatic login:
54
- - On success, return True immediately.
55
- 2. Always attempt manual login if manual_flag is True.
56
- 3. Return True if manual login succeeds, False otherwise.
57
- """
58
- manual_flag = state_mgr.get_manual_login_flag(self._site_key)
59
-
60
- # First try automatic login
61
- if not manual_flag:
62
- if self._requester.login(manual_login=False):
63
- return True
64
-
65
- # try manual login
66
- return self._requester.login(manual_login=True)
67
-
68
48
  def download_one(self, book_id: str) -> None:
69
49
  """
70
50
  The full download logic for a single book.
@@ -72,8 +52,10 @@ class QidianDownloader(BaseDownloader):
72
52
  :param book_id: The identifier of the book to download.
73
53
  """
74
54
  if not self._is_logged_in:
75
- logger.warning(
76
- f"[{self._site_key}] login failed, skipping download of {book_id}"
55
+ self.logger.warning(
56
+ "[%s] login failed, skipping download of %s",
57
+ self._site_key,
58
+ book_id,
77
59
  )
78
60
  return
79
61
 
@@ -81,20 +63,28 @@ class QidianDownloader(BaseDownloader):
81
63
  save_html = self.config.save_html
82
64
  skip_existing = self.config.skip_existing
83
65
  wait_time = self.config.request_interval
66
+ scroll = self.config.mode == "browser"
84
67
 
85
- raw_base = self.raw_data_dir / "qidian" / book_id
86
- cache_base = self.cache_dir / "qidian" / book_id
68
+ raw_base = self.raw_data_dir / book_id
69
+ cache_base = self.cache_dir / book_id
87
70
  info_path = raw_base / "book_info.json"
88
- chapter_dir = raw_base / "chapters"
89
- encrypted_chapter_dir = raw_base / "encrypted_chapters"
90
- if save_html:
91
- chapters_html_dir = cache_base / "html"
71
+ chapters_html_dir = cache_base / "html"
92
72
 
93
73
  raw_base.mkdir(parents=True, exist_ok=True)
94
- chapter_dir.mkdir(parents=True, exist_ok=True)
95
- encrypted_chapter_dir.mkdir(parents=True, exist_ok=True)
74
+ normal_cs = ChapterStorage(
75
+ raw_base=raw_base,
76
+ namespace="chapters",
77
+ backend_type=self._config.storage_backend,
78
+ batch_size=self._config.storage_batch_size,
79
+ )
80
+ encrypted_cs = ChapterStorage(
81
+ raw_base=raw_base,
82
+ namespace="encrypted_chapters",
83
+ backend_type=self._config.storage_backend,
84
+ batch_size=self._config.storage_batch_size,
85
+ )
96
86
 
97
- book_info: Dict[str, Any]
87
+ book_info: dict[str, Any]
98
88
 
99
89
  try:
100
90
  if not info_path.exists():
@@ -103,13 +93,13 @@ class QidianDownloader(BaseDownloader):
103
93
  days, hrs, mins, secs = calculate_time_difference(
104
94
  book_info.get("update_time", ""), "UTC+8"
105
95
  )
106
- logger.info(
96
+ self.logger.info(
107
97
  "%s Last updated %dd %dh %dm %ds ago", TAG, days, hrs, mins, secs
108
98
  )
109
99
  if days > 1:
110
100
  raise FileNotFoundError # trigger re-fetch
111
101
  except Exception:
112
- info_html = self.requester.get_book_info(book_id, wait_time)
102
+ info_html = self.requester.get_book_info(book_id)
113
103
  if save_html:
114
104
  info_html_path = chapters_html_dir / "info.html"
115
105
  save_as_txt(info_html, info_html_path)
@@ -119,29 +109,28 @@ class QidianDownloader(BaseDownloader):
119
109
  and book_info.get("update_time", "") != "未找到更新时间"
120
110
  ):
121
111
  save_as_json(book_info, info_path)
112
+ sleep_with_random_delay(wait_time, mul_spread=1.1, max_sleep=wait_time + 2)
122
113
 
123
114
  # download cover
124
115
  cover_url = book_info.get("cover_url", "")
125
116
  if cover_url:
126
117
  cover_bytes = download_image_as_bytes(cover_url, raw_base)
127
118
  if not cover_bytes:
128
- logger.warning("%s Failed to download cover: %s", TAG, cover_url)
119
+ self.logger.warning("%s Failed to download cover: %s", TAG, cover_url)
129
120
 
130
121
  # enqueue chapters
131
122
  for vol in book_info.get("volumes", []):
132
123
  vol_name = vol.get("volume_name", "")
133
- logger.info("%s Enqueuing volume: %s", TAG, vol_name)
124
+ self.logger.info("%s Enqueuing volume: %s", TAG, vol_name)
134
125
 
135
126
  for chap in vol.get("chapters", []):
136
127
  cid = chap.get("chapterId")
137
128
  if not cid:
138
- logger.warning("%s Skipping chapter without chapterId", TAG)
129
+ self.logger.warning("%s Skipping chapter without chapterId", TAG)
139
130
  continue
140
131
 
141
- chap_path = chapter_dir / f"{cid}.json"
142
-
143
- if chap_path.exists() and skip_existing:
144
- logger.debug(
132
+ if normal_cs.exists(cid) and skip_existing:
133
+ self.logger.debug(
145
134
  "%s Chapter already exists, skipping: %s",
146
135
  TAG,
147
136
  cid,
@@ -149,16 +138,19 @@ class QidianDownloader(BaseDownloader):
149
138
  continue
150
139
 
151
140
  chap_title = chap.get("title", "")
152
- logger.info("%s Fetching chapter: %s (%s)", TAG, chap_title, cid)
153
- chap_html = self.requester.get_book_chapter(book_id, cid, wait_time)
141
+ self.logger.info("%s Fetching chapter: %s (%s)", TAG, chap_title, cid)
142
+ chap_html = self.requester.get_book_chapter(book_id, cid)
143
+ if scroll:
144
+ self.requester.scroll_page(wait_time * 2) # type: ignore[attr-defined]
145
+ else:
146
+ sleep_with_random_delay(
147
+ wait_time, mul_spread=1.1, max_sleep=wait_time + 2
148
+ )
154
149
 
155
150
  is_encrypted = self.parser.is_encrypted(chap_html) # type: ignore[attr-defined]
156
151
 
157
- folder = encrypted_chapter_dir if is_encrypted else chapter_dir
158
- chap_path = folder / f"{cid}.json"
159
-
160
- if chap_path.exists() and skip_existing:
161
- logger.debug(
152
+ if is_encrypted and encrypted_cs.exists(cid) and skip_existing:
153
+ self.logger.debug(
162
154
  "%s Chapter already exists, skipping: %s",
163
155
  TAG,
164
156
  cid,
@@ -171,13 +163,13 @@ class QidianDownloader(BaseDownloader):
171
163
  )
172
164
  html_path = folder / f"{cid}.html"
173
165
  save_as_txt(chap_html, html_path, on_exist="skip")
174
- logger.debug(
166
+ self.logger.debug(
175
167
  "%s Saved raw HTML for chapter %s to %s", TAG, cid, html_path
176
168
  )
177
169
 
178
170
  chap_json = self.parser.parse_chapter(chap_html, cid)
179
171
  if not chap_json:
180
- logger.warning(
172
+ self.logger.warning(
181
173
  "%s Parsed chapter json is empty, skipping: %s (%s)",
182
174
  TAG,
183
175
  chap_title,
@@ -185,18 +177,42 @@ class QidianDownloader(BaseDownloader):
185
177
  )
186
178
  continue
187
179
 
188
- save_as_json(chap_json, chap_path)
189
- logger.info("%s Saved chapter: %s (%s)", TAG, chap_title, cid)
180
+ if is_encrypted:
181
+ encrypted_cs.save(chap_json)
182
+ else:
183
+ normal_cs.save(chap_json)
184
+ self.logger.info("%s Saved chapter: %s (%s)", TAG, chap_title, cid)
185
+
186
+ normal_cs.close()
187
+ encrypted_cs.close()
190
188
 
191
189
  self.saver.save(book_id)
192
190
 
193
- logger.info(
191
+ self.logger.info(
194
192
  "%s Novel '%s' download completed.",
195
193
  TAG,
196
194
  book_info.get("book_name", "unknown"),
197
195
  )
198
196
  return
199
197
 
198
+ def _handle_login(self) -> bool:
199
+ """
200
+ Perform login with automatic fallback to manual:
201
+
202
+ 1. If manual_flag is False, try automatic login:
203
+ - On success, return True immediately.
204
+ 2. Always attempt manual login if manual_flag is True.
205
+ 3. Return True if manual login succeeds, False otherwise.
206
+ """
207
+ manual_flag = state_mgr.get_manual_login_flag(self._site_key)
208
+
209
+ # First try automatic login
210
+ if not manual_flag and self._requester.login(manual_login=False):
211
+ return True
212
+
213
+ # try manual login
214
+ return self._requester.login(manual_login=True)
215
+
200
216
 
201
217
  def is_vip(html_str: str) -> bool:
202
218
  """
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.core.factory
5
4
  -----------------------------
@@ -8,18 +7,18 @@ This package provides factory methods for dynamically retrieving components
8
7
  based on runtime parameters such as site name or content type.
9
8
  """
10
9
 
11
- from .downloader_factory import (
10
+ from .downloader import (
12
11
  get_async_downloader,
13
12
  get_downloader,
14
13
  get_sync_downloader,
15
14
  )
16
- from .parser_factory import get_parser
17
- from .requester_factory import (
15
+ from .parser import get_parser
16
+ from .requester import (
18
17
  get_async_requester,
19
18
  get_requester,
20
19
  get_sync_requester,
21
20
  )
22
- from .saver_factory import get_saver
21
+ from .saver import get_saver
23
22
 
24
23
  __all__ = [
25
24
  "get_async_downloader",
@@ -1,23 +1,17 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.core.factory.downloader_factory
5
4
  ------------------------------------------------
6
5
 
7
6
  This module implements a factory function for creating downloader instances
8
7
  based on the site name and parser mode specified in the configuration.
9
-
10
- - get_async_downloader -> always returns a CommonAsyncDownloader
11
- - get_sync_downloader -> returns a site-specific downloader or CommonDownloader
12
- - get_downloader -> dispatches to one of the above based on config.mode
13
-
14
- To add support for new sites or modes, extend the `_site_map` accordingly.
15
8
  """
16
9
 
17
- from typing import Union
10
+ from typing import cast
18
11
 
19
12
  from novel_downloader.config import DownloaderConfig, load_site_rules
20
13
  from novel_downloader.core.downloaders import (
14
+ BiqugeDownloader,
21
15
  CommonAsyncDownloader,
22
16
  CommonDownloader,
23
17
  QidianDownloader,
@@ -25,15 +19,18 @@ from novel_downloader.core.downloaders import (
25
19
  from novel_downloader.core.interfaces import (
26
20
  AsyncDownloaderProtocol,
27
21
  AsyncRequesterProtocol,
28
- DownloaderProtocol,
29
22
  ParserProtocol,
30
- RequesterProtocol,
31
23
  SaverProtocol,
24
+ SyncDownloaderProtocol,
25
+ SyncRequesterProtocol,
32
26
  )
33
27
 
34
- _site_map = {
28
+ # _async_site_map = {
29
+ # # "biquge": ...
30
+ # }
31
+ _sync_site_map = {
35
32
  "qidian": QidianDownloader,
36
- # "biquge": ...
33
+ "biquge": BiqugeDownloader,
37
34
  }
38
35
 
39
36
 
@@ -56,14 +53,18 @@ def get_async_downloader(
56
53
  :return: An instance of a downloader class
57
54
 
58
55
  :raises ValueError: If a site-specific downloader does not support async mode.
59
- :raises TypeError: If the provided requester does not match the required protocol
60
- for the chosen mode (sync vs async).
56
+ :raises TypeError: If the provided requester does not match the required protocol.
61
57
  """
62
58
  site_key = site.lower()
63
59
 
64
60
  if not isinstance(requester, AsyncRequesterProtocol):
65
61
  raise TypeError("Async mode requires an AsyncRequesterProtocol")
66
62
 
63
+ # site-specific
64
+ # if site_key in _async_site_map:
65
+ # return _async_site_map[site_key](requester, parser, saver, config)
66
+
67
+ # fallback
67
68
  site_rules = load_site_rules()
68
69
  site_rule = site_rules.get(site_key)
69
70
  if site_rule is None:
@@ -73,12 +74,12 @@ def get_async_downloader(
73
74
 
74
75
 
75
76
  def get_sync_downloader(
76
- requester: RequesterProtocol,
77
+ requester: SyncRequesterProtocol,
77
78
  parser: ParserProtocol,
78
79
  saver: SaverProtocol,
79
80
  site: str,
80
81
  config: DownloaderConfig,
81
- ) -> DownloaderProtocol:
82
+ ) -> SyncDownloaderProtocol:
82
83
  """
83
84
  Returns a DownloaderProtocol for the given site.
84
85
  First tries a site-specific downloader (e.g. QidianDownloader),
@@ -93,17 +94,16 @@ def get_sync_downloader(
93
94
  :return: An instance of a downloader class
94
95
 
95
96
  :raises ValueError: If a site-specific downloader does not support async mode.
96
- :raises TypeError: If the provided requester does not match the required protocol
97
- for the chosen mode (sync vs async).
97
+ :raises TypeError: If the provided requester does not match the required protocol.
98
98
  """
99
99
  site_key = site.lower()
100
100
 
101
- if not isinstance(requester, RequesterProtocol):
101
+ if not isinstance(requester, SyncRequesterProtocol):
102
102
  raise TypeError("Sync mode requires a RequesterProtocol")
103
103
 
104
104
  # site-specific
105
- if site_key in _site_map:
106
- return _site_map[site_key](requester, parser, saver, config)
105
+ if site_key in _sync_site_map:
106
+ return _sync_site_map[site_key](requester, parser, saver, config)
107
107
 
108
108
  # fallback
109
109
  site_rules = load_site_rules()
@@ -115,12 +115,12 @@ def get_sync_downloader(
115
115
 
116
116
 
117
117
  def get_downloader(
118
- requester: Union[AsyncRequesterProtocol, RequesterProtocol],
118
+ requester: AsyncRequesterProtocol | SyncRequesterProtocol,
119
119
  parser: ParserProtocol,
120
120
  saver: SaverProtocol,
121
121
  site: str,
122
122
  config: DownloaderConfig,
123
- ) -> Union[AsyncDownloaderProtocol, DownloaderProtocol]:
123
+ ) -> AsyncDownloaderProtocol | SyncDownloaderProtocol:
124
124
  """
125
125
  Dispatches to get_async_downloader if config.mode == 'async',
126
126
  otherwise to get_sync_downloader.
@@ -134,16 +134,17 @@ def get_downloader(
134
134
  :return: An instance of a downloader class
135
135
 
136
136
  :raises ValueError: If a site-specific downloader does not support async mode.
137
- :raises TypeError: If the provided requester does not match the required protocol
138
- for the chosen mode (sync vs async).
137
+ :raises TypeError: If the provided requester does not match the required protocol.
139
138
  """
140
- mode = config.mode.lower()
141
- if mode == "async":
142
- if not isinstance(requester, AsyncRequesterProtocol):
143
- raise TypeError("Async mode requires an AsyncRequesterProtocol")
144
- return get_async_downloader(requester, parser, saver, site, config)
145
- if mode in ("browser", "session"):
146
- if not isinstance(requester, RequesterProtocol):
147
- raise TypeError("Sync mode requires a RequesterProtocol")
148
- return get_sync_downloader(requester, parser, saver, site, config)
149
- raise ValueError(f"Unknown mode '{config.mode}' for site '{site}'")
139
+ if requester.is_async():
140
+ if config.mode.lower() != "async":
141
+ raise TypeError("Requester is async, but config.mode is not 'async'")
142
+ async_requester = cast(AsyncRequesterProtocol, requester)
143
+ return get_async_downloader(async_requester, parser, saver, site, config)
144
+ else:
145
+ if config.mode.lower() not in ("browser", "session"):
146
+ raise TypeError(
147
+ "Requester is sync, but config.mode is not 'browser' or 'session'"
148
+ )
149
+ sync_requester = cast(SyncRequesterProtocol, requester)
150
+ return get_sync_downloader(sync_requester, parser, saver, site, config)
@@ -1,35 +1,33 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.core.factory.parser_factory
5
4
  --------------------------------------------
6
5
 
7
6
  This module implements a factory function for creating parser instances
8
7
  based on the site name and parser mode specified in the configuration.
9
-
10
- Currently supported:
11
- - Site: 'qidian'
12
- - Modes:
13
- - 'browser': QidianBrowserParser
14
- - 'session': (Not implemented yet)
15
-
16
- To add support for new sites or modes, extend the `_site_map` accordingly.
17
8
  """
18
9
 
10
+ from collections.abc import Callable
11
+
19
12
  from novel_downloader.config import ParserConfig, load_site_rules
20
13
  from novel_downloader.core.interfaces import ParserProtocol
21
14
  from novel_downloader.core.parsers import (
15
+ BiqugeParser,
22
16
  CommonParser,
23
17
  QidianBrowserParser,
24
18
  QidianSessionParser,
25
19
  )
26
20
 
27
- _site_map = {
21
+ ParserBuilder = Callable[[ParserConfig], ParserProtocol]
22
+
23
+ _site_map: dict[str, dict[str, ParserBuilder]] = {
28
24
  "qidian": {
29
25
  "browser": QidianBrowserParser,
30
26
  "session": QidianSessionParser,
31
27
  },
32
- # "biquge": ...
28
+ "biquge": {
29
+ "session": BiqugeParser,
30
+ },
33
31
  }
34
32
 
35
33
 
@@ -47,11 +45,11 @@ def get_parser(site: str, config: ParserConfig) -> ParserProtocol:
47
45
  site_entry = _site_map[site_key]
48
46
  if isinstance(site_entry, dict):
49
47
  parser_class = site_entry.get(config.mode)
50
- if parser_class is None:
51
- raise ValueError(f"Unsupported mode '{config.mode}' for site '{site}'")
52
48
  else:
53
49
  parser_class = site_entry
54
- return parser_class(config)
50
+
51
+ if parser_class:
52
+ return parser_class(config)
55
53
 
56
54
  # Fallback: site not mapped specially, try to load rule
57
55
  site_rules = load_site_rules()
@@ -1,38 +1,45 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.core.factory.requester_factory
5
4
  -----------------------------------------------
6
5
 
7
6
  This module implements a factory function for retrieving requester instances
8
7
  based on the target novel platform (site).
9
-
10
- - get_async_requester -> returns AsyncRequesterProtocol
11
- - get_sync_requester -> returns RequesterProtocol
12
- - get_requester -> dispatches to one of the above based on config.mode
13
-
14
- To add support for new sites or modes, extend the `_site_map` accordingly.
15
8
  """
16
9
 
17
- from typing import Callable, Union
10
+ from collections.abc import Callable
18
11
 
19
12
  from novel_downloader.config import RequesterConfig, load_site_rules
20
- from novel_downloader.core.interfaces import AsyncRequesterProtocol, RequesterProtocol
13
+ from novel_downloader.core.interfaces import (
14
+ AsyncRequesterProtocol,
15
+ SyncRequesterProtocol,
16
+ )
21
17
  from novel_downloader.core.requesters import (
18
+ BiqugeSession,
22
19
  CommonAsyncSession,
23
20
  CommonSession,
24
21
  QidianBrowser,
25
22
  QidianSession,
26
23
  )
27
24
 
28
- _site_map: dict[
25
+ AsyncRequesterBuilder = Callable[[RequesterConfig], AsyncRequesterProtocol]
26
+ SyncRequesterBuilder = Callable[[RequesterConfig], SyncRequesterProtocol]
27
+
28
+
29
+ # _async_site_map: dict[str, AsyncRequesterBuilder] = {
30
+ # # "biquge": ...
31
+ # }
32
+ _sync_site_map: dict[
29
33
  str,
30
- dict[str, Callable[[RequesterConfig], RequesterProtocol]],
34
+ dict[str, SyncRequesterBuilder],
31
35
  ] = {
32
36
  "qidian": {
33
37
  "session": QidianSession,
34
38
  "browser": QidianBrowser,
35
39
  },
40
+ "biquge": {
41
+ "session": BiqugeSession,
42
+ },
36
43
  }
37
44
 
38
45
 
@@ -48,6 +55,12 @@ def get_async_requester(
48
55
  :return: An instance of a requester class
49
56
  """
50
57
  site_key = site.lower()
58
+
59
+ # site-specific
60
+ # if site_key in _async_site_map:
61
+ # return _async_site_map[site_key](config)
62
+
63
+ # fallback
51
64
  site_rules = load_site_rules()
52
65
  site_rule = site_rules.get(site_key)
53
66
  if site_rule is None:
@@ -59,7 +72,7 @@ def get_async_requester(
59
72
  def get_sync_requester(
60
73
  site: str,
61
74
  config: RequesterConfig,
62
- ) -> RequesterProtocol:
75
+ ) -> SyncRequesterProtocol:
63
76
  """
64
77
  Returns a RequesterProtocol for the given site.
65
78
 
@@ -68,15 +81,15 @@ def get_sync_requester(
68
81
  :return: An instance of a requester class
69
82
  """
70
83
  site_key = site.lower()
71
- site_entry = _site_map.get(site_key)
84
+ site_entry = _sync_site_map.get(site_key)
72
85
 
73
- # site-specific implementation for this mode
86
+ # site-specific
74
87
  if site_entry:
75
88
  cls = site_entry.get(config.mode)
76
89
  if cls:
77
90
  return cls(config)
78
91
 
79
- # fallback to CommonSession
92
+ # fallback
80
93
  site_rules = load_site_rules()
81
94
  site_rule = site_rules.get(site_key)
82
95
  if site_rule is None:
@@ -88,7 +101,7 @@ def get_sync_requester(
88
101
  def get_requester(
89
102
  site: str,
90
103
  config: RequesterConfig,
91
- ) -> Union[AsyncRequesterProtocol, RequesterProtocol]:
104
+ ) -> AsyncRequesterProtocol | SyncRequesterProtocol:
92
105
  """
93
106
  Dispatches to either get_async_requester or get_sync_requester
94
107
  based on config.mode. Treats 'browser' and 'async' as async modes,
@@ -1,29 +1,23 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.core.factory.parser_factory
5
4
  --------------------------------------------
6
5
 
7
6
  This module implements a factory function for creating saver instances
8
7
  based on the site name and parser mode specified in the configuration.
9
-
10
- Currently supported:
11
- - Site: 'qidian'
12
- - QidianSaver
13
-
14
- To add support for new sites or modes, extend the `_site_map` accordingly.
15
8
  """
16
9
 
17
10
  from novel_downloader.config import SaverConfig, load_site_rules
18
11
  from novel_downloader.core.interfaces import SaverProtocol
19
12
  from novel_downloader.core.savers import (
13
+ BiqugeSaver,
20
14
  CommonSaver,
21
15
  QidianSaver,
22
16
  )
23
17
 
24
18
  _site_map = {
25
19
  "qidian": QidianSaver,
26
- # "biquge": ...
20
+ "biquge": BiqugeSaver,
27
21
  }
28
22
 
29
23
 
@@ -37,11 +31,12 @@ def get_saver(site: str, config: SaverConfig) -> SaverProtocol:
37
31
  """
38
32
  site_key = site.lower()
39
33
 
34
+ # site-specific
40
35
  saver_class = _site_map.get(site_key)
41
36
  if saver_class:
42
37
  return saver_class(config)
43
38
 
44
- # Fallback: check site_rules
39
+ # Fallback
45
40
  site_rules = load_site_rules()
46
41
  if site_key not in site_rules:
47
42
  raise ValueError(f"Unsupported site: {site}")