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.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/download.py +58 -24
- novel_downloader/config/adapter.py +4 -1
- novel_downloader/config/models.py +4 -1
- novel_downloader/core/downloaders/__init__.py +2 -0
- novel_downloader/core/downloaders/base_async_downloader.py +157 -0
- novel_downloader/core/downloaders/common_asynb_downloader.py +207 -0
- novel_downloader/core/downloaders/common_downloader.py +2 -3
- novel_downloader/core/factory/__init__.py +14 -2
- novel_downloader/core/factory/downloader_factory.py +95 -8
- novel_downloader/core/factory/requester_factory.py +65 -21
- novel_downloader/core/interfaces/__init__.py +4 -0
- novel_downloader/core/interfaces/async_downloader_protocol.py +37 -0
- novel_downloader/core/interfaces/async_requester_protocol.py +68 -0
- novel_downloader/core/requesters/__init__.py +5 -1
- novel_downloader/core/requesters/base_async_session.py +297 -0
- novel_downloader/core/requesters/common_requester/__init__.py +5 -1
- novel_downloader/core/requesters/common_requester/common_async_session.py +96 -0
- novel_downloader/core/requesters/qidian_requester/qidian_session.py +1 -1
- novel_downloader/resources/config/settings.yaml +4 -1
- novel_downloader/utils/crypto_utils.py +4 -4
- {novel_downloader-1.1.1.dist-info → novel_downloader-1.2.0.dist-info}/METADATA +27 -7
- {novel_downloader-1.1.1.dist-info → novel_downloader-1.2.0.dist-info}/RECORD +27 -21
- {novel_downloader-1.1.1.dist-info → novel_downloader-1.2.0.dist-info}/WHEEL +0 -0
- {novel_downloader-1.1.1.dist-info → novel_downloader-1.2.0.dist-info}/entry_points.txt +0 -0
- {novel_downloader-1.1.1.dist-info → novel_downloader-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
11
|
-
-
|
12
|
-
|
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
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
11
|
-
-
|
12
|
-
|
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
|
39
|
+
def get_async_requester(
|
40
|
+
site: str,
|
41
|
+
config: RequesterConfig,
|
42
|
+
) -> AsyncRequesterProtocol:
|
37
43
|
"""
|
38
|
-
Returns
|
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
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
62
|
-
|
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
|
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",
|