novel-downloader 1.3.3__py3-none-any.whl → 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/clean.py +97 -78
- novel_downloader/cli/config.py +177 -0
- novel_downloader/cli/download.py +132 -87
- novel_downloader/cli/export.py +77 -0
- novel_downloader/cli/main.py +21 -28
- novel_downloader/config/__init__.py +1 -25
- novel_downloader/config/adapter.py +32 -31
- novel_downloader/config/loader.py +3 -3
- novel_downloader/config/site_rules.py +1 -2
- novel_downloader/core/__init__.py +3 -6
- novel_downloader/core/downloaders/__init__.py +10 -13
- novel_downloader/core/downloaders/base.py +233 -0
- novel_downloader/core/downloaders/biquge.py +27 -0
- novel_downloader/core/downloaders/common.py +414 -0
- novel_downloader/core/downloaders/esjzone.py +27 -0
- novel_downloader/core/downloaders/linovelib.py +27 -0
- novel_downloader/core/downloaders/qianbi.py +27 -0
- novel_downloader/core/downloaders/qidian.py +352 -0
- novel_downloader/core/downloaders/sfacg.py +27 -0
- novel_downloader/core/downloaders/yamibo.py +27 -0
- novel_downloader/core/exporters/__init__.py +37 -0
- novel_downloader/core/{savers → exporters}/base.py +73 -39
- novel_downloader/core/exporters/biquge.py +25 -0
- novel_downloader/core/exporters/common/__init__.py +12 -0
- novel_downloader/core/{savers → exporters}/common/epub.py +22 -22
- novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +35 -40
- novel_downloader/core/{savers → exporters}/common/txt.py +20 -23
- novel_downloader/core/{savers → exporters}/epub_utils/__init__.py +8 -3
- novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -2
- novel_downloader/core/{savers → exporters}/epub_utils/image_loader.py +46 -4
- novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -4
- novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +3 -3
- novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -2
- novel_downloader/core/exporters/esjzone.py +25 -0
- novel_downloader/core/exporters/linovelib/__init__.py +10 -0
- novel_downloader/core/exporters/linovelib/epub.py +449 -0
- novel_downloader/core/exporters/linovelib/main_exporter.py +127 -0
- novel_downloader/core/exporters/linovelib/txt.py +129 -0
- novel_downloader/core/exporters/qianbi.py +25 -0
- novel_downloader/core/{savers → exporters}/qidian.py +8 -8
- novel_downloader/core/exporters/sfacg.py +25 -0
- novel_downloader/core/exporters/yamibo.py +25 -0
- novel_downloader/core/factory/__init__.py +5 -17
- novel_downloader/core/factory/downloader.py +24 -126
- novel_downloader/core/factory/exporter.py +58 -0
- novel_downloader/core/factory/fetcher.py +96 -0
- novel_downloader/core/factory/parser.py +17 -12
- novel_downloader/core/{requesters → fetchers}/__init__.py +22 -15
- novel_downloader/core/{requesters → fetchers}/base/__init__.py +2 -4
- novel_downloader/core/fetchers/base/browser.py +383 -0
- novel_downloader/core/fetchers/base/rate_limiter.py +86 -0
- novel_downloader/core/fetchers/base/session.py +419 -0
- novel_downloader/core/fetchers/biquge/__init__.py +14 -0
- novel_downloader/core/{requesters/biquge/async_session.py → fetchers/biquge/browser.py} +18 -6
- novel_downloader/core/{requesters → fetchers}/biquge/session.py +23 -30
- novel_downloader/core/fetchers/common/__init__.py +14 -0
- novel_downloader/core/fetchers/common/browser.py +79 -0
- novel_downloader/core/{requesters/common/async_session.py → fetchers/common/session.py} +8 -25
- novel_downloader/core/fetchers/esjzone/__init__.py +14 -0
- novel_downloader/core/fetchers/esjzone/browser.py +202 -0
- novel_downloader/core/{requesters/esjzone/async_session.py → fetchers/esjzone/session.py} +62 -42
- novel_downloader/core/fetchers/linovelib/__init__.py +14 -0
- novel_downloader/core/fetchers/linovelib/browser.py +178 -0
- novel_downloader/core/fetchers/linovelib/session.py +178 -0
- novel_downloader/core/fetchers/qianbi/__init__.py +14 -0
- novel_downloader/core/{requesters/qianbi/session.py → fetchers/qianbi/browser.py} +30 -48
- novel_downloader/core/{requesters/qianbi/async_session.py → fetchers/qianbi/session.py} +18 -6
- novel_downloader/core/fetchers/qidian/__init__.py +14 -0
- novel_downloader/core/fetchers/qidian/browser.py +266 -0
- novel_downloader/core/fetchers/qidian/session.py +326 -0
- novel_downloader/core/fetchers/sfacg/__init__.py +14 -0
- novel_downloader/core/fetchers/sfacg/browser.py +189 -0
- novel_downloader/core/{requesters/sfacg/async_session.py → fetchers/sfacg/session.py} +43 -73
- novel_downloader/core/fetchers/yamibo/__init__.py +14 -0
- novel_downloader/core/fetchers/yamibo/browser.py +229 -0
- novel_downloader/core/{requesters/yamibo/async_session.py → fetchers/yamibo/session.py} +62 -44
- novel_downloader/core/interfaces/__init__.py +8 -12
- novel_downloader/core/interfaces/downloader.py +54 -0
- novel_downloader/core/interfaces/{saver.py → exporter.py} +12 -12
- novel_downloader/core/interfaces/fetcher.py +162 -0
- novel_downloader/core/interfaces/parser.py +6 -7
- novel_downloader/core/parsers/__init__.py +5 -6
- novel_downloader/core/parsers/base.py +9 -13
- novel_downloader/core/parsers/biquge/main_parser.py +12 -13
- novel_downloader/core/parsers/common/helper.py +3 -3
- novel_downloader/core/parsers/common/main_parser.py +39 -34
- novel_downloader/core/parsers/esjzone/main_parser.py +20 -14
- novel_downloader/core/parsers/linovelib/__init__.py +10 -0
- novel_downloader/core/parsers/linovelib/main_parser.py +210 -0
- novel_downloader/core/parsers/qianbi/main_parser.py +21 -15
- novel_downloader/core/parsers/qidian/__init__.py +2 -11
- novel_downloader/core/parsers/qidian/book_info_parser.py +113 -0
- novel_downloader/core/parsers/qidian/{browser/chapter_encrypted.py → chapter_encrypted.py} +162 -135
- novel_downloader/core/parsers/qidian/chapter_normal.py +150 -0
- novel_downloader/core/parsers/qidian/{session/chapter_router.py → chapter_router.py} +15 -15
- novel_downloader/core/parsers/qidian/{browser/main_parser.py → main_parser.py} +49 -40
- novel_downloader/core/parsers/qidian/utils/__init__.py +27 -0
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +145 -0
- novel_downloader/core/parsers/qidian/{shared → utils}/helpers.py +41 -68
- novel_downloader/core/parsers/qidian/{session → utils}/node_decryptor.py +64 -50
- novel_downloader/core/parsers/sfacg/main_parser.py +12 -12
- novel_downloader/core/parsers/yamibo/main_parser.py +10 -10
- novel_downloader/locales/en.json +18 -2
- novel_downloader/locales/zh.json +18 -2
- novel_downloader/models/__init__.py +64 -0
- novel_downloader/models/browser.py +21 -0
- novel_downloader/models/chapter.py +25 -0
- novel_downloader/models/config.py +100 -0
- novel_downloader/models/login.py +20 -0
- novel_downloader/models/site_rules.py +99 -0
- novel_downloader/models/tasks.py +33 -0
- novel_downloader/models/types.py +15 -0
- novel_downloader/resources/config/settings.toml +31 -25
- novel_downloader/resources/json/linovelib_font_map.json +3573 -0
- novel_downloader/tui/__init__.py +7 -0
- novel_downloader/tui/app.py +32 -0
- novel_downloader/tui/main.py +17 -0
- novel_downloader/tui/screens/__init__.py +14 -0
- novel_downloader/tui/screens/home.py +191 -0
- novel_downloader/tui/screens/login.py +74 -0
- novel_downloader/tui/styles/home_layout.tcss +79 -0
- novel_downloader/tui/widgets/richlog_handler.py +24 -0
- novel_downloader/utils/__init__.py +6 -0
- novel_downloader/utils/chapter_storage.py +25 -38
- novel_downloader/utils/constants.py +11 -5
- novel_downloader/utils/cookies.py +66 -0
- novel_downloader/utils/crypto_utils.py +1 -74
- novel_downloader/utils/fontocr/ocr_v1.py +2 -1
- novel_downloader/utils/fontocr/ocr_v2.py +2 -2
- novel_downloader/utils/hash_store.py +10 -18
- novel_downloader/utils/hash_utils.py +3 -2
- novel_downloader/utils/logger.py +2 -3
- novel_downloader/utils/network.py +2 -1
- novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
- novel_downloader/utils/text_utils/font_mapping.py +1 -1
- novel_downloader/utils/text_utils/text_cleaning.py +1 -1
- novel_downloader/utils/time_utils/datetime_utils.py +3 -3
- novel_downloader/utils/time_utils/sleep_utils.py +1 -1
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/METADATA +69 -35
- novel_downloader-1.4.0.dist-info/RECORD +170 -0
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/entry_points.txt +1 -0
- novel_downloader/cli/interactive.py +0 -66
- novel_downloader/cli/settings.py +0 -177
- novel_downloader/config/models.py +0 -187
- novel_downloader/core/downloaders/base/__init__.py +0 -14
- novel_downloader/core/downloaders/base/base_async.py +0 -153
- novel_downloader/core/downloaders/base/base_sync.py +0 -208
- novel_downloader/core/downloaders/biquge/__init__.py +0 -14
- novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
- novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
- novel_downloader/core/downloaders/common/__init__.py +0 -14
- novel_downloader/core/downloaders/common/common_async.py +0 -210
- novel_downloader/core/downloaders/common/common_sync.py +0 -202
- novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
- novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
- novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
- novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
- novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
- novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
- novel_downloader/core/downloaders/qidian/__init__.py +0 -10
- novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -219
- novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
- novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
- novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
- novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
- novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
- novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
- novel_downloader/core/factory/requester.py +0 -144
- novel_downloader/core/factory/saver.py +0 -56
- novel_downloader/core/interfaces/async_downloader.py +0 -36
- novel_downloader/core/interfaces/async_requester.py +0 -84
- novel_downloader/core/interfaces/sync_downloader.py +0 -36
- novel_downloader/core/interfaces/sync_requester.py +0 -82
- novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
- novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
- novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
- novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
- novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
- novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
- novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
- novel_downloader/core/requesters/base/async_session.py +0 -410
- novel_downloader/core/requesters/base/browser.py +0 -337
- novel_downloader/core/requesters/base/session.py +0 -378
- novel_downloader/core/requesters/biquge/__init__.py +0 -14
- novel_downloader/core/requesters/common/__init__.py +0 -17
- novel_downloader/core/requesters/common/session.py +0 -113
- novel_downloader/core/requesters/esjzone/__init__.py +0 -13
- novel_downloader/core/requesters/esjzone/session.py +0 -235
- novel_downloader/core/requesters/qianbi/__init__.py +0 -13
- novel_downloader/core/requesters/qidian/__init__.py +0 -21
- novel_downloader/core/requesters/qidian/broswer.py +0 -307
- novel_downloader/core/requesters/qidian/session.py +0 -290
- novel_downloader/core/requesters/sfacg/__init__.py +0 -13
- novel_downloader/core/requesters/sfacg/session.py +0 -242
- novel_downloader/core/requesters/yamibo/__init__.py +0 -13
- novel_downloader/core/requesters/yamibo/session.py +0 -237
- novel_downloader/core/savers/__init__.py +0 -34
- novel_downloader/core/savers/biquge.py +0 -25
- novel_downloader/core/savers/common/__init__.py +0 -12
- novel_downloader/core/savers/esjzone.py +0 -25
- novel_downloader/core/savers/qianbi.py +0 -25
- novel_downloader/core/savers/sfacg.py +0 -25
- novel_downloader/core/savers/yamibo.py +0 -25
- novel_downloader/resources/config/rules.toml +0 -196
- novel_downloader-1.3.3.dist-info/RECORD +0 -166
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/top_level.txt +0 -0
@@ -1,41 +1,40 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
novel_downloader.core.parsers.qidian.
|
4
|
-
|
3
|
+
novel_downloader.core.parsers.qidian.main_parser
|
4
|
+
------------------------------------------------
|
5
5
|
|
6
|
-
Main parser class for handling Qidian
|
7
|
-
|
8
|
-
This module defines `QidianBrowserParser`, a parser implementation that supports
|
9
|
-
content extracted from dynamically rendered Qidian HTML pages.
|
6
|
+
Main parser class for handling Qidian HTML
|
10
7
|
"""
|
11
8
|
|
12
9
|
from __future__ import annotations
|
13
10
|
|
11
|
+
import logging
|
14
12
|
from pathlib import Path
|
15
13
|
from typing import TYPE_CHECKING, Any
|
16
14
|
|
17
|
-
from novel_downloader.config.models import ParserConfig
|
18
15
|
from novel_downloader.core.parsers.base import BaseParser
|
19
|
-
from novel_downloader.
|
16
|
+
from novel_downloader.models import ChapterDict, ParserConfig
|
17
|
+
from novel_downloader.utils.constants import DATA_DIR
|
18
|
+
from novel_downloader.utils.cookies import find_cookie_value
|
20
19
|
|
21
|
-
from
|
22
|
-
is_encrypted,
|
23
|
-
parse_book_info,
|
24
|
-
)
|
20
|
+
from .book_info_parser import parse_book_info
|
25
21
|
from .chapter_router import parse_chapter
|
22
|
+
from .utils import is_encrypted
|
23
|
+
|
24
|
+
logger = logging.getLogger(__name__)
|
26
25
|
|
27
26
|
if TYPE_CHECKING:
|
28
27
|
from novel_downloader.utils.fontocr import FontOCR
|
29
28
|
|
30
29
|
|
31
|
-
class
|
30
|
+
class QidianParser(BaseParser):
|
32
31
|
"""
|
33
|
-
Parser for Qidian site
|
32
|
+
Parser for Qidian site.
|
34
33
|
"""
|
35
34
|
|
36
35
|
def __init__(self, config: ParserConfig):
|
37
36
|
"""
|
38
|
-
Initialize the
|
37
|
+
Initialize the QidianParser with the given configuration.
|
39
38
|
|
40
39
|
:param config: ParserConfig object controlling:
|
41
40
|
"""
|
@@ -49,55 +48,65 @@ class QidianBrowserParser(BaseParser):
|
|
49
48
|
self._fixed_font_dir.mkdir(parents=True, exist_ok=True)
|
50
49
|
self._font_debug_dir: Path | None = None
|
51
50
|
|
51
|
+
state_files = [
|
52
|
+
DATA_DIR / "qidian" / "browser_state.cookies",
|
53
|
+
DATA_DIR / "qidian" / "session_state.cookies",
|
54
|
+
]
|
55
|
+
self._fuid: str = find_cookie_value(state_files, "ywguid")
|
56
|
+
|
52
57
|
self._font_ocr: FontOCR | None = None
|
53
58
|
if self._decode_font:
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
59
|
+
try:
|
60
|
+
from novel_downloader.utils.fontocr import FontOCR
|
61
|
+
except ImportError:
|
62
|
+
logger.warning(
|
63
|
+
"[QidianParser] FontOCR not available, font decoding will skip"
|
64
|
+
)
|
65
|
+
else:
|
66
|
+
self._font_ocr = FontOCR(
|
67
|
+
cache_dir=self._base_cache_dir,
|
68
|
+
use_freq=config.use_freq,
|
69
|
+
use_ocr=config.use_ocr,
|
70
|
+
use_vec=config.use_vec,
|
71
|
+
batch_size=config.batch_size,
|
72
|
+
gpu_mem=config.gpu_mem,
|
73
|
+
gpu_id=config.gpu_id,
|
74
|
+
ocr_weight=config.ocr_weight,
|
75
|
+
vec_weight=config.vec_weight,
|
76
|
+
font_debug=config.save_font_debug,
|
77
|
+
)
|
78
|
+
self._font_debug_dir = self._base_cache_dir / "qidian" / "font_debug"
|
79
|
+
self._font_debug_dir.mkdir(parents=True, exist_ok=True)
|
71
80
|
|
72
81
|
def parse_book_info(
|
73
82
|
self,
|
74
|
-
|
83
|
+
html_list: list[str],
|
75
84
|
**kwargs: Any,
|
76
85
|
) -> dict[str, Any]:
|
77
86
|
"""
|
78
87
|
Parse a book info page and extract metadata and chapter structure.
|
79
88
|
|
80
|
-
:param
|
89
|
+
:param html_list: Raw HTML of the book info page.
|
81
90
|
:return: Parsed metadata and chapter structure as a dictionary.
|
82
91
|
"""
|
83
|
-
if not
|
92
|
+
if not html_list:
|
84
93
|
return {}
|
85
|
-
return parse_book_info(
|
94
|
+
return parse_book_info(html_list[0])
|
86
95
|
|
87
96
|
def parse_chapter(
|
88
97
|
self,
|
89
|
-
|
98
|
+
html_list: list[str],
|
90
99
|
chapter_id: str,
|
91
100
|
**kwargs: Any,
|
92
101
|
) -> ChapterDict | None:
|
93
102
|
"""
|
94
|
-
:param
|
103
|
+
:param html_list: Raw HTML of the chapter page.
|
95
104
|
:param chapter_id: Identifier of the chapter being parsed.
|
96
105
|
:return: Cleaned chapter content as plain text.
|
97
106
|
"""
|
98
|
-
if not
|
107
|
+
if not html_list:
|
99
108
|
return None
|
100
|
-
return parse_chapter(self,
|
109
|
+
return parse_chapter(self, html_list[0], chapter_id)
|
101
110
|
|
102
111
|
def is_encrypted(self, html_str: str) -> bool:
|
103
112
|
"""
|
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.parsers.qidian.utils
|
4
|
+
------------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from .helpers import (
|
9
|
+
can_view_chapter,
|
10
|
+
extract_chapter_info,
|
11
|
+
find_ssr_page_context,
|
12
|
+
is_encrypted,
|
13
|
+
is_restricted_page,
|
14
|
+
vip_status,
|
15
|
+
)
|
16
|
+
from .node_decryptor import QidianNodeDecryptor, get_decryptor
|
17
|
+
|
18
|
+
__all__ = [
|
19
|
+
"find_ssr_page_context",
|
20
|
+
"extract_chapter_info",
|
21
|
+
"is_restricted_page",
|
22
|
+
"vip_status",
|
23
|
+
"can_view_chapter",
|
24
|
+
"is_encrypted",
|
25
|
+
"QidianNodeDecryptor",
|
26
|
+
"get_decryptor",
|
27
|
+
]
|
@@ -0,0 +1,145 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.parsers.qidian.utils.decryptor_fetcher
|
4
|
+
------------------------------------------------------------
|
5
|
+
|
6
|
+
Download and cache the *qidian-decryptor* executable from the project's
|
7
|
+
GitHub releases.
|
8
|
+
"""
|
9
|
+
|
10
|
+
from __future__ import annotations
|
11
|
+
|
12
|
+
import hashlib
|
13
|
+
import platform
|
14
|
+
import stat
|
15
|
+
from pathlib import Path
|
16
|
+
from typing import Final
|
17
|
+
|
18
|
+
import requests
|
19
|
+
|
20
|
+
from novel_downloader.utils.constants import JS_SCRIPT_DIR
|
21
|
+
|
22
|
+
DEST_ROOT: Final[Path] = JS_SCRIPT_DIR
|
23
|
+
GITHUB_OWNER: Final = "BowenZ217"
|
24
|
+
GITHUB_REPO: Final = "qidian-decryptor"
|
25
|
+
RELEASE_VERSION: Final = "v1.0.1"
|
26
|
+
BASE_URL: Final = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{RELEASE_VERSION}"
|
27
|
+
PLATFORM_BINARIES: Final[dict[str, str]] = {
|
28
|
+
"linux": "qidian-decryptor-linux",
|
29
|
+
"macos": "qidian-decryptor-macos",
|
30
|
+
"win": "qidian-decryptor-win.exe",
|
31
|
+
}
|
32
|
+
|
33
|
+
|
34
|
+
# --------------------------------------------------------------------------- #
|
35
|
+
# API
|
36
|
+
# --------------------------------------------------------------------------- #
|
37
|
+
|
38
|
+
|
39
|
+
def ensure_decryptor(dest_root: Path | None = None) -> Path:
|
40
|
+
"""
|
41
|
+
Ensure that the decryptor executable matching the current platform and
|
42
|
+
:data:`RELEASE_VERSION` exists locally; download it if necessary.
|
43
|
+
|
44
|
+
:param dest_root: Root directory used to cache the binary.
|
45
|
+
If *None*, the global constant ``JS_SCRIPT_DIR`` is used.
|
46
|
+
:return: Path to the ready-to-use executable (inside the version sub-folder).
|
47
|
+
:raises RuntimeError: If the current platform is unsupported.
|
48
|
+
:raises ValueError: If the downloaded file fails SHA-256 verification.
|
49
|
+
"""
|
50
|
+
dest_root = DEST_ROOT if dest_root is None else Path(dest_root).expanduser()
|
51
|
+
platform_key = _get_platform_key()
|
52
|
+
|
53
|
+
bin_name = PLATFORM_BINARIES[platform_key]
|
54
|
+
# 版本: /<version>/<binary>
|
55
|
+
version_dir = dest_root / RELEASE_VERSION.lstrip("v")
|
56
|
+
dest_path = version_dir / bin_name
|
57
|
+
|
58
|
+
if dest_path.exists():
|
59
|
+
return dest_path
|
60
|
+
|
61
|
+
version_dir.mkdir(parents=True, exist_ok=True)
|
62
|
+
_download_binary(platform_key, dest_path)
|
63
|
+
_make_executable(dest_path)
|
64
|
+
|
65
|
+
return dest_path
|
66
|
+
|
67
|
+
|
68
|
+
# --------------------------------------------------------------------------- #
|
69
|
+
# helper functions
|
70
|
+
# --------------------------------------------------------------------------- #
|
71
|
+
|
72
|
+
|
73
|
+
def _get_platform_key() -> str:
|
74
|
+
sys = platform.system().lower()
|
75
|
+
if "windows" in sys:
|
76
|
+
return "win"
|
77
|
+
if "linux" in sys:
|
78
|
+
return "linux"
|
79
|
+
if "darwin" in sys:
|
80
|
+
return "macos"
|
81
|
+
raise RuntimeError(f"Unsupported platform: {sys}")
|
82
|
+
|
83
|
+
|
84
|
+
def _download_binary(platform_key: str, dest_path: Path) -> None:
|
85
|
+
"""
|
86
|
+
Download the binary for *platform_key*, verify its SHA-256 checksum against
|
87
|
+
the release-wide ``SHA256SUMS`` manifest, and write it to *dest_path*.
|
88
|
+
|
89
|
+
:param platform_key: Key in :data:`PLATFORM_BINARIES` ("linux" | "macos" | "win").
|
90
|
+
:param dest_path: Target path where the binary will be saved.
|
91
|
+
:raises RuntimeError: If the checksum for the binary is missing in the manifest.
|
92
|
+
:raises ValueError: If the downloaded file fails SHA-256 verification.
|
93
|
+
"""
|
94
|
+
bin_name = PLATFORM_BINARIES[platform_key]
|
95
|
+
|
96
|
+
manifest_url = f"{BASE_URL}/SHA256SUMS"
|
97
|
+
manifest_resp = requests.get(manifest_url, timeout=10)
|
98
|
+
manifest_resp.raise_for_status()
|
99
|
+
|
100
|
+
expected_hash: str | None = None
|
101
|
+
for line in manifest_resp.text.splitlines():
|
102
|
+
parts = line.strip().split()
|
103
|
+
if len(parts) == 2 and parts[1] == bin_name:
|
104
|
+
expected_hash = parts[0]
|
105
|
+
break
|
106
|
+
|
107
|
+
if expected_hash is None:
|
108
|
+
raise RuntimeError(f"Checksum for {bin_name!r} not found in SHA256SUMS")
|
109
|
+
|
110
|
+
file_url = f"{BASE_URL}/{bin_name}"
|
111
|
+
resp = requests.get(file_url, timeout=30)
|
112
|
+
resp.raise_for_status()
|
113
|
+
dest_path.write_bytes(resp.content)
|
114
|
+
|
115
|
+
if _sha256sum(dest_path) != expected_hash:
|
116
|
+
dest_path.unlink(missing_ok=True)
|
117
|
+
raise ValueError("SHA256 mismatch — download corrupted, file removed.")
|
118
|
+
|
119
|
+
|
120
|
+
def _sha256sum(p: Path) -> str:
|
121
|
+
h = hashlib.sha256()
|
122
|
+
with p.open("rb") as f:
|
123
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
124
|
+
h.update(chunk)
|
125
|
+
return h.hexdigest()
|
126
|
+
|
127
|
+
|
128
|
+
def _make_executable(p: Path) -> None:
|
129
|
+
"""
|
130
|
+
Add executable permission bits on Unix-like systems; keep the file unchanged
|
131
|
+
on Windows. Any *PermissionError* raised by ``chmod`` is silently ignored.
|
132
|
+
|
133
|
+
:param p: Path to the downloaded binary that should be made executable.
|
134
|
+
"""
|
135
|
+
try:
|
136
|
+
mode = p.stat().st_mode
|
137
|
+
p.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
138
|
+
except PermissionError:
|
139
|
+
pass
|
140
|
+
|
141
|
+
|
142
|
+
__all__ = [
|
143
|
+
"ensure_decryptor",
|
144
|
+
"RELEASE_VERSION",
|
145
|
+
]
|
@@ -1,12 +1,11 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
novel_downloader.core.parsers.qidian.
|
4
|
-
|
3
|
+
novel_downloader.core.parsers.qidian.utils.helpers
|
4
|
+
--------------------------------------------------
|
5
5
|
|
6
|
-
Shared utility functions for parsing Qidian
|
6
|
+
Shared utility functions for parsing Qidian pages.
|
7
7
|
|
8
8
|
This module provides reusable helpers to:
|
9
|
-
- Convert HTML into BeautifulSoup objects with fallback.
|
10
9
|
- Extract SSR-rendered JSON page context and structured chapter metadata.
|
11
10
|
- Identify VIP chapters, encrypted content, and viewability conditions.
|
12
11
|
"""
|
@@ -15,28 +14,51 @@ import json
|
|
15
14
|
import logging
|
16
15
|
from typing import Any
|
17
16
|
|
18
|
-
from
|
17
|
+
from lxml import html
|
19
18
|
|
20
19
|
logger = logging.getLogger(__name__)
|
21
20
|
|
22
21
|
|
23
|
-
def
|
22
|
+
def find_ssr_page_context(html_str: str) -> dict[str, Any]:
|
24
23
|
"""
|
25
|
-
|
26
|
-
|
27
|
-
:param html_str: Raw HTML string.
|
28
|
-
:return: Parsed BeautifulSoup object.
|
24
|
+
Extract SSR JSON from <script id="vite-plugin-ssr_pageContext">.
|
29
25
|
"""
|
30
26
|
try:
|
31
|
-
|
27
|
+
tree = html.fromstring(html_str)
|
28
|
+
script = tree.xpath('//script[@id="vite-plugin-ssr_pageContext"]/text()')
|
29
|
+
if script:
|
30
|
+
data: dict[str, Any] = json.loads(script[0].strip())
|
31
|
+
return data
|
32
32
|
except Exception as e:
|
33
|
-
logger.warning("[Parser]
|
34
|
-
|
33
|
+
logger.warning("[Parser] SSR JSON parse error: %s", e)
|
34
|
+
return {}
|
35
35
|
|
36
36
|
|
37
|
-
def
|
37
|
+
def extract_chapter_info(ssr_data: dict[str, Any]) -> dict[str, Any]:
|
38
38
|
"""
|
39
|
-
|
39
|
+
Extract the 'chapterInfo' dictionary from the SSR page context.
|
40
|
+
|
41
|
+
This handles nested key access and returns an empty dict if missing.
|
42
|
+
|
43
|
+
:param ssr_data: The full SSR data object from _find_ssr_page_context().
|
44
|
+
:return: A dict with chapter metadata such as chapterName, authorSay, etc.
|
45
|
+
"""
|
46
|
+
try:
|
47
|
+
page_context = ssr_data.get("pageContext", {})
|
48
|
+
page_props = page_context.get("pageProps", {})
|
49
|
+
page_data = page_props.get("pageData", {})
|
50
|
+
chapter_info = page_data.get("chapterInfo", {})
|
51
|
+
|
52
|
+
assert isinstance(chapter_info, dict)
|
53
|
+
return chapter_info
|
54
|
+
except Exception:
|
55
|
+
return {}
|
56
|
+
|
57
|
+
|
58
|
+
def is_restricted_page(html_str: str) -> bool:
|
59
|
+
"""
|
60
|
+
Return True if page content indicates access restriction
|
61
|
+
(e.g. not subscribed/purchased).
|
40
62
|
|
41
63
|
:param html_str: Raw HTML string.
|
42
64
|
"""
|
@@ -44,38 +66,30 @@ def is_vip(html_str: str) -> bool:
|
|
44
66
|
return any(m in html_str for m in markers)
|
45
67
|
|
46
68
|
|
47
|
-
def vip_status(
|
69
|
+
def vip_status(ssr_data: dict[str, Any]) -> bool:
|
48
70
|
"""
|
49
|
-
:param soup: Parsed BeautifulSoup object of the HTML page.
|
50
71
|
:return: True if VIP, False otherwise.
|
51
72
|
"""
|
52
|
-
ssr_data = find_ssr_page_context(soup)
|
53
73
|
chapter_info = extract_chapter_info(ssr_data)
|
54
74
|
vip_flag = chapter_info.get("vipStatus", 0)
|
55
75
|
fens_flag = chapter_info.get("fEnS", 0)
|
56
76
|
return bool(vip_flag == 1 and fens_flag != 0)
|
57
77
|
|
58
78
|
|
59
|
-
def can_view_chapter(
|
79
|
+
def can_view_chapter(ssr_data: dict[str, Any]) -> bool:
|
60
80
|
"""
|
61
|
-
Return True if the chapter is viewable by the current user.
|
62
|
-
|
63
81
|
A chapter is not viewable if it is marked as VIP
|
64
82
|
and has not been purchased.
|
65
83
|
|
66
|
-
:param soup: Parsed BeautifulSoup object of the HTML page.
|
67
84
|
:return: True if viewable, False otherwise.
|
68
85
|
"""
|
69
|
-
ssr_data = find_ssr_page_context(soup)
|
70
86
|
chapter_info = extract_chapter_info(ssr_data)
|
71
|
-
|
72
87
|
is_buy = chapter_info.get("isBuy", 0)
|
73
88
|
vip_status = chapter_info.get("vipStatus", 0)
|
74
|
-
|
75
89
|
return not (vip_status == 1 and is_buy == 0)
|
76
90
|
|
77
91
|
|
78
|
-
def is_encrypted(content: str |
|
92
|
+
def is_encrypted(content: str | dict[str, Any]) -> bool:
|
79
93
|
"""
|
80
94
|
Return True if content is encrypted.
|
81
95
|
|
@@ -86,47 +100,6 @@ def is_encrypted(content: str | BeautifulSoup) -> bool:
|
|
86
100
|
:param content: HTML content, either as a raw string or a BeautifulSoup object.
|
87
101
|
:return: True if encrypted marker is found, else False.
|
88
102
|
"""
|
89
|
-
|
90
|
-
# return bool(main and "r-font-encrypt" in main.get("class", []))
|
91
|
-
# Normalize to BeautifulSoup
|
92
|
-
soup = html_to_soup(content) if isinstance(content, str) else content
|
93
|
-
|
94
|
-
ssr_data = find_ssr_page_context(soup)
|
103
|
+
ssr_data = find_ssr_page_context(content) if isinstance(content, str) else content
|
95
104
|
chapter_info = extract_chapter_info(ssr_data)
|
96
105
|
return int(chapter_info.get("cES", 0)) == 2
|
97
|
-
|
98
|
-
|
99
|
-
def find_ssr_page_context(soup: BeautifulSoup) -> dict[str, Any]:
|
100
|
-
"""
|
101
|
-
Extract SSR JSON from <script id="vite-plugin-ssr_pageContext">.
|
102
|
-
"""
|
103
|
-
try:
|
104
|
-
tag = soup.find("script", id="vite-plugin-ssr_pageContext")
|
105
|
-
if isinstance(tag, Tag) and tag.string:
|
106
|
-
data: dict[str, Any] = json.loads(tag.string.strip())
|
107
|
-
return data
|
108
|
-
except Exception as e:
|
109
|
-
logger.warning("[Parser] SSR JSON parse error: %s", e)
|
110
|
-
return {}
|
111
|
-
|
112
|
-
|
113
|
-
def extract_chapter_info(ssr_data: dict[str, Any]) -> dict[str, Any]:
|
114
|
-
"""
|
115
|
-
Extract the 'chapterInfo' dictionary from the SSR page context.
|
116
|
-
|
117
|
-
This handles nested key access and returns an empty dict if missing.
|
118
|
-
|
119
|
-
:param ssr_data: The full SSR data object from _find_ssr_page_context().
|
120
|
-
:return: A dict with chapter metadata such as chapterName, authorSay, etc.
|
121
|
-
"""
|
122
|
-
try:
|
123
|
-
page_context = ssr_data.get("pageContext", {})
|
124
|
-
page_props = page_context.get("pageProps", {})
|
125
|
-
page_data = page_props.get("pageData", {})
|
126
|
-
chapter_info = page_data.get("chapterInfo", {})
|
127
|
-
|
128
|
-
assert isinstance(chapter_info, dict)
|
129
|
-
return chapter_info
|
130
|
-
except Exception as e:
|
131
|
-
logger.warning("[Parser] Failed to extract chapterInfo: %s", e)
|
132
|
-
return {}
|
@@ -1,7 +1,7 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
novel_downloader.core.parsers.qidian.
|
4
|
-
|
3
|
+
novel_downloader.core.parsers.qidian.utils.node_decryptor
|
4
|
+
---------------------------------------------------------
|
5
5
|
|
6
6
|
Provides QidianNodeDecryptor, which ensures a Node.js environment,
|
7
7
|
downloads or installs the required JS modules (Fock + decrypt script),
|
@@ -20,6 +20,8 @@ from novel_downloader.utils.constants import (
|
|
20
20
|
QD_DECRYPT_SCRIPT_PATH,
|
21
21
|
)
|
22
22
|
|
23
|
+
from .decryptor_fetcher import ensure_decryptor
|
24
|
+
|
23
25
|
logger = logging.getLogger(__name__)
|
24
26
|
|
25
27
|
|
@@ -49,59 +51,60 @@ class QidianNodeDecryptor:
|
|
49
51
|
|
50
52
|
def __init__(self) -> None:
|
51
53
|
"""
|
52
|
-
|
53
|
-
|
54
|
+
Initialise the decryptor environment and decide which executable will be
|
55
|
+
used (`node` script or the pre-built binary).
|
54
56
|
"""
|
55
57
|
self.script_dir: Path = JS_SCRIPT_DIR
|
56
58
|
self.script_dir.mkdir(parents=True, exist_ok=True)
|
57
|
-
|
59
|
+
|
60
|
+
self._script_cmd: list[str] | None = None
|
58
61
|
self._check_environment()
|
59
62
|
|
60
63
|
def _check_environment(self) -> None:
|
61
64
|
"""
|
62
|
-
|
63
|
-
package resources, and the Fock JS module is downloaded.
|
64
|
-
|
65
|
-
:raises EnvironmentError: if `node` is not on the system PATH.
|
65
|
+
Decide which decryptor backend to use and make sure it is ready.
|
66
66
|
"""
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
67
|
+
try:
|
68
|
+
# 1) Check Node.js
|
69
|
+
if not shutil.which("node"):
|
70
|
+
raise OSError("Node.js is not installed or not in PATH.")
|
71
|
+
|
72
|
+
# 2) Copy bundled decrypt script into place if missing
|
73
|
+
if not self.QIDIAN_DECRYPT_SCRIPT_PATH.exists():
|
74
|
+
try:
|
75
|
+
resource = QD_DECRYPT_SCRIPT_PATH
|
76
|
+
shutil.copyfile(str(resource), str(self.QIDIAN_DECRYPT_SCRIPT_PATH))
|
77
|
+
except Exception as e:
|
78
|
+
logger.error("[decryptor] Failed to copy decrypt script: %s", e)
|
79
|
+
raise
|
80
|
+
|
81
|
+
# 3) Download the Fock JS module from Qidian CDN if missing
|
82
|
+
if not self.QIDIAN_FOCK_JS_PATH.exists():
|
83
|
+
from novel_downloader.utils.network import download_js_file
|
84
|
+
|
85
|
+
try:
|
86
|
+
download_js_file(
|
87
|
+
self.QIDIAN_FOCK_JS_URL,
|
88
|
+
self.script_dir,
|
89
|
+
on_exist="overwrite",
|
90
|
+
)
|
91
|
+
except Exception as e:
|
92
|
+
logger.error("[decryptor] Failed to download Fock JS module: %s", e)
|
93
|
+
raise
|
94
|
+
self._script_cmd = ["node", str(self.QIDIAN_DECRYPT_SCRIPT_PATH)]
|
95
|
+
return
|
96
|
+
except Exception:
|
88
97
|
try:
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
)
|
94
|
-
logger.info(
|
95
|
-
"[decryptor] Downloaded Fock module to %s", self.QIDIAN_FOCK_JS_PATH
|
96
|
-
)
|
97
|
-
except Exception as e:
|
98
|
-
logger.error("[decryptor] Failed to download Fock JS module: %s", e)
|
99
|
-
raise
|
98
|
+
self._script_cmd = [str(ensure_decryptor(self.script_dir))]
|
99
|
+
except Exception as exc:
|
100
|
+
raise OSError(
|
101
|
+
"Neither Node.js nor fallback binary is available."
|
102
|
+
) from exc
|
100
103
|
|
101
104
|
def decrypt(
|
102
105
|
self,
|
103
106
|
ciphertext: str | bytes,
|
104
|
-
chapter_id: str
|
107
|
+
chapter_id: str,
|
105
108
|
fkp: str,
|
106
109
|
fuid: str,
|
107
110
|
) -> str:
|
@@ -115,6 +118,10 @@ class QidianNodeDecryptor:
|
|
115
118
|
:return: The decrypted plain-text content.
|
116
119
|
:raises RuntimeError: if the Node.js subprocess exits with a non-zero code.
|
117
120
|
"""
|
121
|
+
if not self._script_cmd:
|
122
|
+
return ""
|
123
|
+
if not (ciphertext and chapter_id and fkp and fuid):
|
124
|
+
return ""
|
118
125
|
# Normalize inputs
|
119
126
|
cipher_str = (
|
120
127
|
ciphertext.decode("utf-8")
|
@@ -135,15 +142,9 @@ class QidianNodeDecryptor:
|
|
135
142
|
encoding="utf-8",
|
136
143
|
)
|
137
144
|
|
138
|
-
|
139
|
-
"[decryptor] Invoking Node.js: node %s %s %s",
|
140
|
-
self.script_path.name,
|
141
|
-
input_path.name,
|
142
|
-
output_path.name,
|
143
|
-
)
|
144
|
-
|
145
|
+
cmd = self._script_cmd + [input_path.name, output_path.name]
|
145
146
|
proc = subprocess.run(
|
146
|
-
|
147
|
+
cmd,
|
147
148
|
capture_output=True,
|
148
149
|
text=True,
|
149
150
|
cwd=str(self.script_dir),
|
@@ -159,3 +160,16 @@ class QidianNodeDecryptor:
|
|
159
160
|
# Clean up temp files
|
160
161
|
input_path.unlink(missing_ok=True)
|
161
162
|
output_path.unlink(missing_ok=True)
|
163
|
+
|
164
|
+
|
165
|
+
_decryptor: QidianNodeDecryptor | None = None
|
166
|
+
|
167
|
+
|
168
|
+
def get_decryptor() -> QidianNodeDecryptor:
|
169
|
+
"""
|
170
|
+
Return the singleton QidianNodeDecryptor, initializing it on first use.
|
171
|
+
"""
|
172
|
+
global _decryptor
|
173
|
+
if _decryptor is None:
|
174
|
+
_decryptor = QidianNodeDecryptor()
|
175
|
+
return _decryptor
|