novel-downloader 1.1.1__py3-none-any.whl → 1.2.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 (27) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/download.py +58 -24
  3. novel_downloader/config/adapter.py +4 -1
  4. novel_downloader/config/models.py +4 -1
  5. novel_downloader/core/downloaders/__init__.py +2 -0
  6. novel_downloader/core/downloaders/base_async_downloader.py +157 -0
  7. novel_downloader/core/downloaders/common_asynb_downloader.py +207 -0
  8. novel_downloader/core/downloaders/common_downloader.py +2 -3
  9. novel_downloader/core/factory/__init__.py +14 -2
  10. novel_downloader/core/factory/downloader_factory.py +95 -8
  11. novel_downloader/core/factory/requester_factory.py +65 -21
  12. novel_downloader/core/interfaces/__init__.py +4 -0
  13. novel_downloader/core/interfaces/async_downloader_protocol.py +37 -0
  14. novel_downloader/core/interfaces/async_requester_protocol.py +68 -0
  15. novel_downloader/core/requesters/__init__.py +5 -1
  16. novel_downloader/core/requesters/base_async_session.py +297 -0
  17. novel_downloader/core/requesters/common_requester/__init__.py +5 -1
  18. novel_downloader/core/requesters/common_requester/common_async_session.py +96 -0
  19. novel_downloader/core/requesters/qidian_requester/qidian_session.py +1 -1
  20. novel_downloader/resources/config/settings.yaml +4 -1
  21. novel_downloader/utils/crypto_utils.py +4 -4
  22. {novel_downloader-1.1.1.dist-info → novel_downloader-1.2.0.dist-info}/METADATA +27 -7
  23. {novel_downloader-1.1.1.dist-info → novel_downloader-1.2.0.dist-info}/RECORD +27 -21
  24. {novel_downloader-1.1.1.dist-info → novel_downloader-1.2.0.dist-info}/WHEEL +0 -0
  25. {novel_downloader-1.1.1.dist-info → novel_downloader-1.2.0.dist-info}/entry_points.txt +0 -0
  26. {novel_downloader-1.1.1.dist-info → novel_downloader-1.2.0.dist-info}/licenses/LICENSE +0 -0
  27. {novel_downloader-1.1.1.dist-info → novel_downloader-1.2.0.dist-info}/top_level.txt +0 -0
@@ -7,19 +7,24 @@ novel_downloader.core.factory.downloader_factory
7
7
  This module implements a factory function for creating downloader instances
8
8
  based on the site name and parser mode specified in the configuration.
9
9
 
10
- Currently supported:
11
- - Site: 'qidian'
12
- - QidianDownloader
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
13
 
14
14
  To add support for new sites or modes, extend the `_site_map` accordingly.
15
15
  """
16
16
 
17
+ from typing import Union
18
+
17
19
  from novel_downloader.config import DownloaderConfig, load_site_rules
18
20
  from novel_downloader.core.downloaders import (
21
+ CommonAsyncDownloader,
19
22
  CommonDownloader,
20
23
  QidianDownloader,
21
24
  )
22
25
  from novel_downloader.core.interfaces import (
26
+ AsyncDownloaderProtocol,
27
+ AsyncRequesterProtocol,
23
28
  DownloaderProtocol,
24
29
  ParserProtocol,
25
30
  RequesterProtocol,
@@ -32,7 +37,42 @@ _site_map = {
32
37
  }
33
38
 
34
39
 
35
- def get_downloader(
40
+ def get_async_downloader(
41
+ requester: AsyncRequesterProtocol,
42
+ parser: ParserProtocol,
43
+ saver: SaverProtocol,
44
+ site: str,
45
+ config: DownloaderConfig,
46
+ ) -> AsyncDownloaderProtocol:
47
+ """
48
+ Returns an AsyncDownloaderProtocol for the given site.
49
+
50
+ :param requester: Requester implementation
51
+ :param parser: Parser implementation
52
+ :param saver: Saver implementation
53
+ :param site: Site name (e.g., 'qidian')
54
+ :param config: Downloader configuration
55
+
56
+ :return: An instance of a downloader class
57
+
58
+ :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).
61
+ """
62
+ site_key = site.lower()
63
+
64
+ if not isinstance(requester, AsyncRequesterProtocol):
65
+ raise TypeError("Async mode requires an AsyncRequesterProtocol")
66
+
67
+ site_rules = load_site_rules()
68
+ site_rule = site_rules.get(site_key)
69
+ if site_rule is None:
70
+ raise ValueError(f"Unsupported site: {site}")
71
+
72
+ return CommonAsyncDownloader(requester, parser, saver, config, site_key)
73
+
74
+
75
+ def get_sync_downloader(
36
76
  requester: RequesterProtocol,
37
77
  parser: ParserProtocol,
38
78
  saver: SaverProtocol,
@@ -40,23 +80,70 @@ def get_downloader(
40
80
  config: DownloaderConfig,
41
81
  ) -> DownloaderProtocol:
42
82
  """
43
- Returns a site-specific downloader instance.
83
+ Returns a DownloaderProtocol for the given site.
84
+ First tries a site-specific downloader (e.g. QidianDownloader),
85
+ otherwise falls back to CommonDownloader.
44
86
 
45
87
  :param requester: Requester implementation
46
88
  :param parser: Parser implementation
47
89
  :param saver: Saver implementation
48
90
  :param site: Site name (e.g., 'qidian')
49
91
  :param config: Downloader configuration
92
+
50
93
  :return: An instance of a downloader class
94
+
95
+ :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).
51
98
  """
52
99
  site_key = site.lower()
53
100
 
101
+ if not isinstance(requester, RequesterProtocol):
102
+ raise TypeError("Sync mode requires a RequesterProtocol")
103
+
104
+ # site-specific
54
105
  if site_key in _site_map:
55
- downloader_class = _site_map[site_key]
56
- return downloader_class(requester, parser, saver, config)
106
+ return _site_map[site_key](requester, parser, saver, config)
57
107
 
108
+ # fallback
58
109
  site_rules = load_site_rules()
59
- if site_key not in site_rules:
110
+ site_rule = site_rules.get(site_key)
111
+ if site_rule is None:
60
112
  raise ValueError(f"Unsupported site: {site}")
61
113
 
62
114
  return CommonDownloader(requester, parser, saver, config, site_key)
115
+
116
+
117
+ def get_downloader(
118
+ requester: Union[AsyncRequesterProtocol, RequesterProtocol],
119
+ parser: ParserProtocol,
120
+ saver: SaverProtocol,
121
+ site: str,
122
+ config: DownloaderConfig,
123
+ ) -> Union[AsyncDownloaderProtocol, DownloaderProtocol]:
124
+ """
125
+ Dispatches to get_async_downloader if config.mode == 'async',
126
+ otherwise to get_sync_downloader.
127
+
128
+ :param requester: Requester implementation
129
+ :param parser: Parser implementation
130
+ :param saver: Saver implementation
131
+ :param site: Site name (e.g., 'qidian')
132
+ :param config: Downloader configuration
133
+
134
+ :return: An instance of a downloader class
135
+
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).
139
+ """
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}'")
@@ -7,56 +7,100 @@ novel_downloader.core.factory.requester_factory
7
7
  This module implements a factory function for retrieving requester instances
8
8
  based on the target novel platform (site).
9
9
 
10
- Currently supported:
11
- - Site: 'qidian'
12
- - Modes:
13
- - 'browser': QidianBrowser
14
- - 'session': (Not implemented yet)
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
15
13
 
16
14
  To add support for new sites or modes, extend the `_site_map` accordingly.
17
15
  """
18
16
 
17
+ from typing import Callable, Union
18
+
19
19
  from novel_downloader.config import RequesterConfig, load_site_rules
20
- from novel_downloader.core.interfaces import RequesterProtocol
20
+ from novel_downloader.core.interfaces import AsyncRequesterProtocol, RequesterProtocol
21
21
  from novel_downloader.core.requesters import (
22
+ CommonAsyncSession,
22
23
  CommonSession,
23
24
  QidianBrowser,
24
25
  QidianSession,
25
26
  )
26
27
 
27
- _site_map = {
28
+ _site_map: dict[
29
+ str,
30
+ dict[str, Callable[[RequesterConfig], RequesterProtocol]],
31
+ ] = {
28
32
  "qidian": {
29
- "browser": QidianBrowser,
30
33
  "session": QidianSession,
34
+ "browser": QidianBrowser,
31
35
  },
32
- # "biquge": ...
33
36
  }
34
37
 
35
38
 
36
- def get_requester(site: str, config: RequesterConfig) -> RequesterProtocol:
39
+ def get_async_requester(
40
+ site: str,
41
+ config: RequesterConfig,
42
+ ) -> AsyncRequesterProtocol:
37
43
  """
38
- Returns a site-specific requester instance.
44
+ Returns an AsyncRequesterProtocol for the given site.
39
45
 
40
46
  :param site: Site name (e.g., 'qidian')
41
47
  :param config: Configuration for the requester
42
48
  :return: An instance of a requester class
43
49
  """
44
50
  site_key = site.lower()
51
+ site_rules = load_site_rules()
52
+ site_rule = site_rules.get(site_key)
53
+ if site_rule is None:
54
+ raise ValueError(f"Unsupported site: {site}")
55
+ profile = site_rule["profile"]
56
+ return CommonAsyncSession(config, site_key, profile)
57
+
58
+
59
+ def get_sync_requester(
60
+ site: str,
61
+ config: RequesterConfig,
62
+ ) -> RequesterProtocol:
63
+ """
64
+ Returns a RequesterProtocol for the given site.
45
65
 
66
+ :param site: Site name (e.g., 'qidian')
67
+ :param config: Configuration for the requester
68
+ :return: An instance of a requester class
69
+ """
70
+ site_key = site.lower()
46
71
  site_entry = _site_map.get(site_key)
72
+
73
+ # site-specific implementation for this mode
47
74
  if site_entry:
48
- requester_class = (
49
- site_entry.get(config.mode) if isinstance(site_entry, dict) else site_entry
50
- )
51
- if requester_class:
52
- return requester_class(config)
53
- raise ValueError(f"Unsupported mode '{config.mode}' for site '{site}'")
54
-
55
- # Fallback: Load site rules
75
+ cls = site_entry.get(config.mode)
76
+ if cls:
77
+ return cls(config)
78
+
79
+ # fallback to CommonSession
56
80
  site_rules = load_site_rules()
57
81
  site_rule = site_rules.get(site_key)
58
82
  if site_rule is None:
59
83
  raise ValueError(f"Unsupported site: {site}")
84
+ profile = site_rule["profile"]
85
+ return CommonSession(config, site_key, profile)
86
+
60
87
 
61
- site_profile = site_rule["profile"]
62
- return CommonSession(config, site_key, site_profile)
88
+ def get_requester(
89
+ site: str,
90
+ config: RequesterConfig,
91
+ ) -> Union[AsyncRequesterProtocol, RequesterProtocol]:
92
+ """
93
+ Dispatches to either get_async_requester or get_sync_requester
94
+ based on config.mode. Treats 'browser' and 'async' as async modes,
95
+ 'session' as sync; anything else is an error.
96
+
97
+ :param site: Site name (e.g., 'qidian')
98
+ :param config: Configuration for the requester
99
+ :return: An instance of a requester class
100
+ """
101
+ mode = config.mode.lower()
102
+ if mode == "async":
103
+ return get_async_requester(site, config)
104
+ if mode in ("browser", "session"):
105
+ return get_sync_requester(site, config)
106
+ raise ValueError(f"Unknown mode '{config.mode}' for site '{site}'")
@@ -15,12 +15,16 @@ Included protocols:
15
15
  - SaverProtocol
16
16
  """
17
17
 
18
+ from .async_downloader_protocol import AsyncDownloaderProtocol
19
+ from .async_requester_protocol import AsyncRequesterProtocol
18
20
  from .downloader_protocol import DownloaderProtocol
19
21
  from .parser_protocol import ParserProtocol
20
22
  from .requester_protocol import RequesterProtocol
21
23
  from .saver_protocol import SaverProtocol
22
24
 
23
25
  __all__ = [
26
+ "AsyncDownloaderProtocol",
27
+ "AsyncRequesterProtocol",
24
28
  "DownloaderProtocol",
25
29
  "ParserProtocol",
26
30
  "RequesterProtocol",
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ novel_downloader.core.interfaces.async_downloader_protocol
5
+ ----------------------------------------------------------
6
+
7
+ This module defines the AsyncDownloaderProtocol, a structural interface
8
+ that outlines the expected behavior of any downloader class.
9
+ """
10
+
11
+ from typing import List, Protocol
12
+
13
+
14
+ class AsyncDownloaderProtocol(Protocol):
15
+ """
16
+ Protocol for fully‐asynchronous downloader classes.
17
+
18
+ Defines the expected interface for any downloader implementation,
19
+ including both batch and single book downloads,
20
+ as well as optional pre-download hooks.
21
+ """
22
+
23
+ async def download(self, book_ids: List[str]) -> None:
24
+ """
25
+ Batch download entry point.
26
+
27
+ :param book_ids: List of book IDs to download.
28
+ """
29
+ ...
30
+
31
+ async def download_one(self, book_id: str) -> None:
32
+ """
33
+ Download logic for a single book.
34
+
35
+ :param book_id: The identifier of the book.
36
+ """
37
+ ...
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ novel_downloader.core.interfaces.async_requester_protocol
5
+ --------------------------------------------------------
6
+
7
+ Defines the AsyncRequesterProtocol interface for fetching raw HTML or JSON
8
+ for book info pages, individual chapters, managing request lifecycle,
9
+ and optionally retrieving a user's authenticated bookcase — all in async style.
10
+ """
11
+
12
+ from typing import Optional, Protocol, runtime_checkable
13
+
14
+
15
+ @runtime_checkable
16
+ class AsyncRequesterProtocol(Protocol):
17
+ """
18
+ An async requester must be able to fetch raw HTML/data for:
19
+ - a book's info page,
20
+ - a specific chapter page,
21
+ and manage login/shutdown asynchronously.
22
+ """
23
+
24
+ async def login(self, max_retries: int = 3, manual_login: bool = False) -> bool:
25
+ """
26
+ Attempt to log in asynchronously.
27
+ :returns: True if login succeeded.
28
+ """
29
+ ...
30
+
31
+ async def get_book_info(self, book_id: str, wait_time: Optional[int] = None) -> str:
32
+ """
33
+ Fetch the raw HTML (or JSON) of the book info page asynchronously.
34
+
35
+ :param book_id: The book identifier.
36
+ :param wait_time: Base number of seconds to wait before returning content.
37
+ :return: The page content as a string.
38
+ """
39
+ ...
40
+
41
+ async def get_book_chapter(
42
+ self, book_id: str, chapter_id: str, wait_time: Optional[int] = None
43
+ ) -> str:
44
+ """
45
+ Fetch the raw HTML (or JSON) of a single chapter asynchronously.
46
+
47
+ :param book_id: The book identifier.
48
+ :param chapter_id: The chapter identifier.
49
+ :param wait_time: Base number of seconds to wait before returning content.
50
+ :return: The chapter content as a string.
51
+ """
52
+ ...
53
+
54
+ async def get_bookcase(self, wait_time: Optional[int] = None) -> str:
55
+ """
56
+ Optional: Retrieve the HTML content of the authenticated
57
+ user's bookcase page asynchronously.
58
+
59
+ :param wait_time: Base number of seconds to wait before returning content.
60
+ :return: The HTML markup of the bookcase page.
61
+ """
62
+ ...
63
+
64
+ async def shutdown(self) -> None:
65
+ """
66
+ Shutdown and clean up any resources (e.g., close aiohttp session).
67
+ """
68
+ ...
@@ -14,13 +14,17 @@ Subpackages:
14
14
  - qidian_requester: Handles all Qidian-related requesting logic.
15
15
  """
16
16
 
17
- from .common_requester import CommonSession
17
+ from .common_requester import (
18
+ CommonAsyncSession,
19
+ CommonSession,
20
+ )
18
21
  from .qidian_requester import (
19
22
  QidianBrowser,
20
23
  QidianSession,
21
24
  )
22
25
 
23
26
  __all__ = [
27
+ "CommonAsyncSession",
24
28
  "CommonSession",
25
29
  "QidianBrowser",
26
30
  "QidianSession",