novel-downloader 1.2.2__py3-none-any.whl → 1.3.1__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 (128) 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 +16 -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 +32 -27
  10. novel_downloader/config/loader.py +116 -108
  11. novel_downloader/config/models.py +35 -29
  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} +33 -21
  23. novel_downloader/core/downloaders/qidian/__init__.py +10 -0
  24. novel_downloader/core/downloaders/{qidian_downloader.py → qidian/qidian_sync.py} +79 -62
  25. novel_downloader/core/factory/__init__.py +4 -5
  26. novel_downloader/core/factory/{downloader_factory.py → downloader.py} +25 -26
  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} +23 -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} +31 -17
  37. novel_downloader/core/parsers/__init__.py +5 -4
  38. novel_downloader/core/parsers/{base_parser.py → base.py} +18 -9
  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 +13 -13
  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 +40 -48
  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 +14 -10
  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 +36 -44
  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 +14 -10
  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_parser → qidian}/shared/book_info_parser.py +5 -6
  58. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/helpers.py +7 -8
  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} +177 -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 +307 -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} +23 -51
  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 +8 -4
  86. novel_downloader/locales/zh.json +5 -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 +6 -4
  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 +8 -10
  113. novel_downloader/utils/time_utils/sleep_utils.py +1 -3
  114. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/METADATA +14 -17
  115. novel_downloader-1.3.1.dist-info/RECORD +127 -0
  116. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/WHEEL +1 -1
  117. novel_downloader/core/requesters/base_browser.py +0 -214
  118. novel_downloader/core/requesters/base_session.py +0 -246
  119. novel_downloader/core/requesters/common_requester/common_async_session.py +0 -98
  120. novel_downloader/core/requesters/common_requester/common_session.py +0 -126
  121. novel_downloader/core/requesters/qidian_requester/__init__.py +0 -22
  122. novel_downloader/core/requesters/qidian_requester/qidian_broswer.py +0 -396
  123. novel_downloader/core/requesters/qidian_requester/qidian_session.py +0 -202
  124. novel_downloader/resources/config/settings.yaml +0 -76
  125. novel_downloader-1.2.2.dist-info/RECORD +0 -115
  126. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/entry_points.txt +0 -0
  127. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/licenses/LICENSE +0 -0
  128. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.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,19 +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
71
  chapters_html_dir = cache_base / "html"
91
72
 
92
73
  raw_base.mkdir(parents=True, exist_ok=True)
93
- chapter_dir.mkdir(parents=True, exist_ok=True)
94
- 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
+ )
95
86
 
96
- book_info: Dict[str, Any]
87
+ book_info: dict[str, Any]
97
88
 
98
89
  try:
99
90
  if not info_path.exists():
@@ -102,13 +93,13 @@ class QidianDownloader(BaseDownloader):
102
93
  days, hrs, mins, secs = calculate_time_difference(
103
94
  book_info.get("update_time", ""), "UTC+8"
104
95
  )
105
- logger.info(
96
+ self.logger.info(
106
97
  "%s Last updated %dd %dh %dm %ds ago", TAG, days, hrs, mins, secs
107
98
  )
108
99
  if days > 1:
109
100
  raise FileNotFoundError # trigger re-fetch
110
101
  except Exception:
111
- info_html = self.requester.get_book_info(book_id, wait_time)
102
+ info_html = self.requester.get_book_info(book_id)
112
103
  if save_html:
113
104
  info_html_path = chapters_html_dir / "info.html"
114
105
  save_as_txt(info_html, info_html_path)
@@ -118,29 +109,28 @@ class QidianDownloader(BaseDownloader):
118
109
  and book_info.get("update_time", "") != "未找到更新时间"
119
110
  ):
120
111
  save_as_json(book_info, info_path)
112
+ sleep_with_random_delay(wait_time, mul_spread=1.1, max_sleep=wait_time + 2)
121
113
 
122
114
  # download cover
123
115
  cover_url = book_info.get("cover_url", "")
124
116
  if cover_url:
125
117
  cover_bytes = download_image_as_bytes(cover_url, raw_base)
126
118
  if not cover_bytes:
127
- logger.warning("%s Failed to download cover: %s", TAG, cover_url)
119
+ self.logger.warning("%s Failed to download cover: %s", TAG, cover_url)
128
120
 
129
121
  # enqueue chapters
130
122
  for vol in book_info.get("volumes", []):
131
123
  vol_name = vol.get("volume_name", "")
132
- logger.info("%s Enqueuing volume: %s", TAG, vol_name)
124
+ self.logger.info("%s Enqueuing volume: %s", TAG, vol_name)
133
125
 
134
126
  for chap in vol.get("chapters", []):
135
127
  cid = chap.get("chapterId")
136
128
  if not cid:
137
- logger.warning("%s Skipping chapter without chapterId", TAG)
129
+ self.logger.warning("%s Skipping chapter without chapterId", TAG)
138
130
  continue
139
131
 
140
- chap_path = chapter_dir / f"{cid}.json"
141
-
142
- if chap_path.exists() and skip_existing:
143
- logger.debug(
132
+ if normal_cs.exists(cid) and skip_existing:
133
+ self.logger.debug(
144
134
  "%s Chapter already exists, skipping: %s",
145
135
  TAG,
146
136
  cid,
@@ -148,16 +138,19 @@ class QidianDownloader(BaseDownloader):
148
138
  continue
149
139
 
150
140
  chap_title = chap.get("title", "")
151
- logger.info("%s Fetching chapter: %s (%s)", TAG, chap_title, cid)
152
- 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
+ )
153
149
 
154
150
  is_encrypted = self.parser.is_encrypted(chap_html) # type: ignore[attr-defined]
155
151
 
156
- folder = encrypted_chapter_dir if is_encrypted else chapter_dir
157
- chap_path = folder / f"{cid}.json"
158
-
159
- if chap_path.exists() and skip_existing:
160
- logger.debug(
152
+ if is_encrypted and encrypted_cs.exists(cid) and skip_existing:
153
+ self.logger.debug(
161
154
  "%s Chapter already exists, skipping: %s",
162
155
  TAG,
163
156
  cid,
@@ -170,13 +163,13 @@ class QidianDownloader(BaseDownloader):
170
163
  )
171
164
  html_path = folder / f"{cid}.html"
172
165
  save_as_txt(chap_html, html_path, on_exist="skip")
173
- logger.debug(
166
+ self.logger.debug(
174
167
  "%s Saved raw HTML for chapter %s to %s", TAG, cid, html_path
175
168
  )
176
169
 
177
170
  chap_json = self.parser.parse_chapter(chap_html, cid)
178
171
  if not chap_json:
179
- logger.warning(
172
+ self.logger.warning(
180
173
  "%s Parsed chapter json is empty, skipping: %s (%s)",
181
174
  TAG,
182
175
  chap_title,
@@ -184,18 +177,42 @@ class QidianDownloader(BaseDownloader):
184
177
  )
185
178
  continue
186
179
 
187
- save_as_json(chap_json, chap_path)
188
- 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()
189
188
 
190
189
  self.saver.save(book_id)
191
190
 
192
- logger.info(
191
+ self.logger.info(
193
192
  "%s Novel '%s' download completed.",
194
193
  TAG,
195
194
  book_info.get("book_name", "unknown"),
196
195
  )
197
196
  return
198
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
+
199
216
 
200
217
  def is_vip(html_str: str) -> bool:
201
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, cast
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,8 +134,7 @@ 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
139
  if requester.is_async():
141
140
  if config.mode.lower() != "async":
@@ -147,5 +146,5 @@ def get_downloader(
147
146
  raise TypeError(
148
147
  "Requester is sync, but config.mode is not 'browser' or 'session'"
149
148
  )
150
- sync_requester = cast(RequesterProtocol, requester)
149
+ sync_requester = cast(SyncRequesterProtocol, requester)
151
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}")
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.core.interfaces
5
4
  --------------------------------
@@ -15,18 +14,18 @@ Included protocols:
15
14
  - SaverProtocol
16
15
  """
17
16
 
18
- from .async_downloader_protocol import AsyncDownloaderProtocol
19
- from .async_requester_protocol import AsyncRequesterProtocol
20
- from .downloader_protocol import DownloaderProtocol
21
- from .parser_protocol import ParserProtocol
22
- from .requester_protocol import RequesterProtocol
23
- from .saver_protocol import SaverProtocol
17
+ from .async_downloader import AsyncDownloaderProtocol
18
+ from .async_requester import AsyncRequesterProtocol
19
+ from .parser import ParserProtocol
20
+ from .saver import SaverProtocol
21
+ from .sync_downloader import SyncDownloaderProtocol
22
+ from .sync_requester import SyncRequesterProtocol
24
23
 
25
24
  __all__ = [
26
25
  "AsyncDownloaderProtocol",
27
26
  "AsyncRequesterProtocol",
28
- "DownloaderProtocol",
29
27
  "ParserProtocol",
30
- "RequesterProtocol",
31
28
  "SaverProtocol",
29
+ "SyncDownloaderProtocol",
30
+ "SyncRequesterProtocol",
32
31
  ]