novel-downloader 1.3.2__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.
Files changed (213) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/clean.py +97 -78
  3. novel_downloader/cli/config.py +177 -0
  4. novel_downloader/cli/download.py +132 -87
  5. novel_downloader/cli/export.py +77 -0
  6. novel_downloader/cli/main.py +21 -28
  7. novel_downloader/config/__init__.py +1 -25
  8. novel_downloader/config/adapter.py +32 -31
  9. novel_downloader/config/loader.py +3 -3
  10. novel_downloader/config/site_rules.py +1 -2
  11. novel_downloader/core/__init__.py +3 -6
  12. novel_downloader/core/downloaders/__init__.py +10 -13
  13. novel_downloader/core/downloaders/base.py +233 -0
  14. novel_downloader/core/downloaders/biquge.py +27 -0
  15. novel_downloader/core/downloaders/common.py +414 -0
  16. novel_downloader/core/downloaders/esjzone.py +27 -0
  17. novel_downloader/core/downloaders/linovelib.py +27 -0
  18. novel_downloader/core/downloaders/qianbi.py +27 -0
  19. novel_downloader/core/downloaders/qidian.py +352 -0
  20. novel_downloader/core/downloaders/sfacg.py +27 -0
  21. novel_downloader/core/downloaders/yamibo.py +27 -0
  22. novel_downloader/core/exporters/__init__.py +37 -0
  23. novel_downloader/core/{savers → exporters}/base.py +73 -44
  24. novel_downloader/core/exporters/biquge.py +25 -0
  25. novel_downloader/core/exporters/common/__init__.py +12 -0
  26. novel_downloader/core/{savers → exporters}/common/epub.py +40 -52
  27. novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +36 -39
  28. novel_downloader/core/{savers → exporters}/common/txt.py +20 -24
  29. novel_downloader/core/exporters/epub_utils/__init__.py +40 -0
  30. novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -1
  31. novel_downloader/core/exporters/epub_utils/image_loader.py +131 -0
  32. novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -3
  33. novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +49 -2
  34. novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -1
  35. novel_downloader/core/exporters/esjzone.py +25 -0
  36. novel_downloader/core/exporters/linovelib/__init__.py +10 -0
  37. novel_downloader/core/exporters/linovelib/epub.py +449 -0
  38. novel_downloader/core/exporters/linovelib/main_exporter.py +127 -0
  39. novel_downloader/core/exporters/linovelib/txt.py +129 -0
  40. novel_downloader/core/exporters/qianbi.py +25 -0
  41. novel_downloader/core/{savers → exporters}/qidian.py +8 -8
  42. novel_downloader/core/exporters/sfacg.py +25 -0
  43. novel_downloader/core/exporters/yamibo.py +25 -0
  44. novel_downloader/core/factory/__init__.py +5 -17
  45. novel_downloader/core/factory/downloader.py +24 -126
  46. novel_downloader/core/factory/exporter.py +58 -0
  47. novel_downloader/core/factory/fetcher.py +96 -0
  48. novel_downloader/core/factory/parser.py +17 -12
  49. novel_downloader/core/{requesters → fetchers}/__init__.py +22 -15
  50. novel_downloader/core/{requesters → fetchers}/base/__init__.py +2 -4
  51. novel_downloader/core/fetchers/base/browser.py +383 -0
  52. novel_downloader/core/fetchers/base/rate_limiter.py +86 -0
  53. novel_downloader/core/fetchers/base/session.py +419 -0
  54. novel_downloader/core/fetchers/biquge/__init__.py +14 -0
  55. novel_downloader/core/{requesters/biquge/async_session.py → fetchers/biquge/browser.py} +18 -6
  56. novel_downloader/core/{requesters → fetchers}/biquge/session.py +23 -30
  57. novel_downloader/core/fetchers/common/__init__.py +14 -0
  58. novel_downloader/core/fetchers/common/browser.py +79 -0
  59. novel_downloader/core/{requesters/common/async_session.py → fetchers/common/session.py} +8 -25
  60. novel_downloader/core/fetchers/esjzone/__init__.py +14 -0
  61. novel_downloader/core/fetchers/esjzone/browser.py +202 -0
  62. novel_downloader/core/{requesters/esjzone/async_session.py → fetchers/esjzone/session.py} +62 -42
  63. novel_downloader/core/fetchers/linovelib/__init__.py +14 -0
  64. novel_downloader/core/fetchers/linovelib/browser.py +178 -0
  65. novel_downloader/core/fetchers/linovelib/session.py +178 -0
  66. novel_downloader/core/fetchers/qianbi/__init__.py +14 -0
  67. novel_downloader/core/{requesters/qianbi/session.py → fetchers/qianbi/browser.py} +30 -48
  68. novel_downloader/core/{requesters/qianbi/async_session.py → fetchers/qianbi/session.py} +18 -6
  69. novel_downloader/core/fetchers/qidian/__init__.py +14 -0
  70. novel_downloader/core/fetchers/qidian/browser.py +266 -0
  71. novel_downloader/core/fetchers/qidian/session.py +326 -0
  72. novel_downloader/core/fetchers/sfacg/__init__.py +14 -0
  73. novel_downloader/core/fetchers/sfacg/browser.py +189 -0
  74. novel_downloader/core/{requesters/sfacg/async_session.py → fetchers/sfacg/session.py} +43 -73
  75. novel_downloader/core/fetchers/yamibo/__init__.py +14 -0
  76. novel_downloader/core/fetchers/yamibo/browser.py +229 -0
  77. novel_downloader/core/{requesters/yamibo/async_session.py → fetchers/yamibo/session.py} +62 -44
  78. novel_downloader/core/interfaces/__init__.py +8 -12
  79. novel_downloader/core/interfaces/downloader.py +54 -0
  80. novel_downloader/core/interfaces/{saver.py → exporter.py} +12 -12
  81. novel_downloader/core/interfaces/fetcher.py +162 -0
  82. novel_downloader/core/interfaces/parser.py +6 -7
  83. novel_downloader/core/parsers/__init__.py +5 -6
  84. novel_downloader/core/parsers/base.py +9 -13
  85. novel_downloader/core/parsers/biquge/main_parser.py +12 -13
  86. novel_downloader/core/parsers/common/helper.py +3 -3
  87. novel_downloader/core/parsers/common/main_parser.py +39 -34
  88. novel_downloader/core/parsers/esjzone/main_parser.py +24 -17
  89. novel_downloader/core/parsers/linovelib/__init__.py +10 -0
  90. novel_downloader/core/parsers/linovelib/main_parser.py +210 -0
  91. novel_downloader/core/parsers/qianbi/main_parser.py +21 -15
  92. novel_downloader/core/parsers/qidian/__init__.py +2 -11
  93. novel_downloader/core/parsers/qidian/book_info_parser.py +113 -0
  94. novel_downloader/core/parsers/qidian/{browser/chapter_encrypted.py → chapter_encrypted.py} +162 -135
  95. novel_downloader/core/parsers/qidian/chapter_normal.py +150 -0
  96. novel_downloader/core/parsers/qidian/{session/chapter_router.py → chapter_router.py} +15 -15
  97. novel_downloader/core/parsers/qidian/{browser/main_parser.py → main_parser.py} +49 -40
  98. novel_downloader/core/parsers/qidian/utils/__init__.py +27 -0
  99. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +145 -0
  100. novel_downloader/core/parsers/qidian/{shared → utils}/helpers.py +41 -68
  101. novel_downloader/core/parsers/qidian/{session → utils}/node_decryptor.py +64 -50
  102. novel_downloader/core/parsers/sfacg/main_parser.py +12 -12
  103. novel_downloader/core/parsers/yamibo/main_parser.py +10 -10
  104. novel_downloader/locales/en.json +18 -2
  105. novel_downloader/locales/zh.json +18 -2
  106. novel_downloader/models/__init__.py +64 -0
  107. novel_downloader/models/browser.py +21 -0
  108. novel_downloader/models/chapter.py +25 -0
  109. novel_downloader/models/config.py +100 -0
  110. novel_downloader/models/login.py +20 -0
  111. novel_downloader/models/site_rules.py +99 -0
  112. novel_downloader/models/tasks.py +33 -0
  113. novel_downloader/models/types.py +15 -0
  114. novel_downloader/resources/config/settings.toml +31 -25
  115. novel_downloader/resources/json/linovelib_font_map.json +3573 -0
  116. novel_downloader/tui/__init__.py +7 -0
  117. novel_downloader/tui/app.py +32 -0
  118. novel_downloader/tui/main.py +17 -0
  119. novel_downloader/tui/screens/__init__.py +14 -0
  120. novel_downloader/tui/screens/home.py +191 -0
  121. novel_downloader/tui/screens/login.py +74 -0
  122. novel_downloader/tui/styles/home_layout.tcss +79 -0
  123. novel_downloader/tui/widgets/richlog_handler.py +24 -0
  124. novel_downloader/utils/__init__.py +6 -0
  125. novel_downloader/utils/chapter_storage.py +25 -38
  126. novel_downloader/utils/constants.py +15 -5
  127. novel_downloader/utils/cookies.py +66 -0
  128. novel_downloader/utils/crypto_utils.py +1 -74
  129. novel_downloader/utils/file_utils/io.py +1 -1
  130. novel_downloader/utils/fontocr/ocr_v1.py +2 -1
  131. novel_downloader/utils/fontocr/ocr_v2.py +2 -2
  132. novel_downloader/utils/hash_store.py +10 -18
  133. novel_downloader/utils/hash_utils.py +3 -2
  134. novel_downloader/utils/logger.py +2 -3
  135. novel_downloader/utils/network.py +53 -39
  136. novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
  137. novel_downloader/utils/text_utils/font_mapping.py +1 -1
  138. novel_downloader/utils/text_utils/text_cleaning.py +1 -1
  139. novel_downloader/utils/time_utils/datetime_utils.py +3 -3
  140. novel_downloader/utils/time_utils/sleep_utils.py +3 -3
  141. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/METADATA +72 -38
  142. novel_downloader-1.4.0.dist-info/RECORD +170 -0
  143. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
  144. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/entry_points.txt +1 -0
  145. novel_downloader/cli/interactive.py +0 -66
  146. novel_downloader/cli/settings.py +0 -177
  147. novel_downloader/config/models.py +0 -187
  148. novel_downloader/core/downloaders/base/__init__.py +0 -14
  149. novel_downloader/core/downloaders/base/base_async.py +0 -153
  150. novel_downloader/core/downloaders/base/base_sync.py +0 -208
  151. novel_downloader/core/downloaders/biquge/__init__.py +0 -14
  152. novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
  153. novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
  154. novel_downloader/core/downloaders/common/__init__.py +0 -14
  155. novel_downloader/core/downloaders/common/common_async.py +0 -218
  156. novel_downloader/core/downloaders/common/common_sync.py +0 -210
  157. novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
  158. novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
  159. novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
  160. novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
  161. novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
  162. novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
  163. novel_downloader/core/downloaders/qidian/__init__.py +0 -10
  164. novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -227
  165. novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
  166. novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
  167. novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
  168. novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
  169. novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
  170. novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
  171. novel_downloader/core/factory/requester.py +0 -144
  172. novel_downloader/core/factory/saver.py +0 -56
  173. novel_downloader/core/interfaces/async_downloader.py +0 -36
  174. novel_downloader/core/interfaces/async_requester.py +0 -84
  175. novel_downloader/core/interfaces/sync_downloader.py +0 -36
  176. novel_downloader/core/interfaces/sync_requester.py +0 -82
  177. novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
  178. novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
  179. novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
  180. novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
  181. novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
  182. novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
  183. novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
  184. novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
  185. novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
  186. novel_downloader/core/requesters/base/async_session.py +0 -410
  187. novel_downloader/core/requesters/base/browser.py +0 -337
  188. novel_downloader/core/requesters/base/session.py +0 -378
  189. novel_downloader/core/requesters/biquge/__init__.py +0 -14
  190. novel_downloader/core/requesters/common/__init__.py +0 -17
  191. novel_downloader/core/requesters/common/session.py +0 -113
  192. novel_downloader/core/requesters/esjzone/__init__.py +0 -13
  193. novel_downloader/core/requesters/esjzone/session.py +0 -235
  194. novel_downloader/core/requesters/qianbi/__init__.py +0 -13
  195. novel_downloader/core/requesters/qidian/__init__.py +0 -21
  196. novel_downloader/core/requesters/qidian/broswer.py +0 -307
  197. novel_downloader/core/requesters/qidian/session.py +0 -290
  198. novel_downloader/core/requesters/sfacg/__init__.py +0 -13
  199. novel_downloader/core/requesters/sfacg/session.py +0 -242
  200. novel_downloader/core/requesters/yamibo/__init__.py +0 -13
  201. novel_downloader/core/requesters/yamibo/session.py +0 -237
  202. novel_downloader/core/savers/__init__.py +0 -34
  203. novel_downloader/core/savers/biquge.py +0 -25
  204. novel_downloader/core/savers/common/__init__.py +0 -12
  205. novel_downloader/core/savers/epub_utils/__init__.py +0 -26
  206. novel_downloader/core/savers/esjzone.py +0 -25
  207. novel_downloader/core/savers/qianbi.py +0 -25
  208. novel_downloader/core/savers/sfacg.py +0 -25
  209. novel_downloader/core/savers/yamibo.py +0 -25
  210. novel_downloader/resources/config/rules.toml +0 -196
  211. novel_downloader-1.3.2.dist-info/RECORD +0 -165
  212. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/licenses/LICENSE +0 -0
  213. {novel_downloader-1.3.2.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.browser.main_parser
4
- --------------------------------------------------------
3
+ novel_downloader.core.parsers.qidian.main_parser
4
+ ------------------------------------------------
5
5
 
6
- Main parser class for handling Qidian chapters rendered via a browser environment.
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.utils.chapter_storage import ChapterDict
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 ..shared import (
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 QidianBrowserParser(BaseParser):
30
+ class QidianParser(BaseParser):
32
31
  """
33
- Parser for Qidian site using a browser-rendered HTML workflow.
32
+ Parser for Qidian site.
34
33
  """
35
34
 
36
35
  def __init__(self, config: ParserConfig):
37
36
  """
38
- Initialize the QidianBrowserParser with the given configuration.
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
- from novel_downloader.utils.fontocr import FontOCR
55
-
56
- self._font_ocr = FontOCR(
57
- cache_dir=self._base_cache_dir,
58
- use_freq=config.use_freq,
59
- ocr_version=config.ocr_version,
60
- use_ocr=config.use_ocr,
61
- use_vec=config.use_vec,
62
- batch_size=config.batch_size,
63
- gpu_mem=config.gpu_mem,
64
- gpu_id=config.gpu_id,
65
- ocr_weight=config.ocr_weight,
66
- vec_weight=config.vec_weight,
67
- font_debug=config.save_font_debug,
68
- )
69
- self._font_debug_dir = self._base_cache_dir / "qidian" / "font_debug"
70
- self._font_debug_dir.mkdir(parents=True, exist_ok=True)
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
- html_str: list[str],
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 html_str: Raw HTML of the book info page.
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 html_str:
92
+ if not html_list:
84
93
  return {}
85
- return parse_book_info(html_str[0])
94
+ return parse_book_info(html_list[0])
86
95
 
87
96
  def parse_chapter(
88
97
  self,
89
- html_str: list[str],
98
+ html_list: list[str],
90
99
  chapter_id: str,
91
100
  **kwargs: Any,
92
101
  ) -> ChapterDict | None:
93
102
  """
94
- :param html: Raw HTML of the chapter page.
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 html_str:
107
+ if not html_list:
99
108
  return None
100
- return parse_chapter(self, html_str[0], chapter_id)
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.shared.helpers
4
- ---------------------------------------------------
3
+ novel_downloader.core.parsers.qidian.utils.helpers
4
+ --------------------------------------------------
5
5
 
6
- Shared utility functions for parsing Qidian browser-rendered pages.
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 bs4 import BeautifulSoup, Tag
17
+ from lxml import html
19
18
 
20
19
  logger = logging.getLogger(__name__)
21
20
 
22
21
 
23
- def html_to_soup(html_str: str) -> BeautifulSoup:
22
+ def find_ssr_page_context(html_str: str) -> dict[str, Any]:
24
23
  """
25
- Convert an HTML string to a BeautifulSoup object with fallback.
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
- return BeautifulSoup(html_str, "lxml")
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] lxml parse failed, falling back: %s", e)
34
- return BeautifulSoup(html_str, "html.parser")
33
+ logger.warning("[Parser] SSR JSON parse error: %s", e)
34
+ return {}
35
35
 
36
36
 
37
- def is_vip(html_str: str) -> bool:
37
+ def extract_chapter_info(ssr_data: dict[str, Any]) -> dict[str, Any]:
38
38
  """
39
- Return True if page indicates VIP‐only content.
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(soup: BeautifulSoup) -> bool:
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(soup: BeautifulSoup) -> bool:
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 | BeautifulSoup) -> bool:
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
- # main = soup.select_one("div#app div#reader-content main")
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.session.node_decryptor
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
- Prepare the script directory and verify that both Node.js
53
- and the necessary JS files are available.
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
- self.script_path: Path = self.QIDIAN_DECRYPT_SCRIPT_PATH
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
- Ensure Node.js is installed, our decrypt script is copied from
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
- # 1) Check Node.js
68
- if not shutil.which("node"):
69
- raise OSError("Node.js is not installed or not in PATH.")
70
-
71
- # 2) Copy bundled decrypt script into place if missing
72
- if not self.QIDIAN_DECRYPT_SCRIPT_PATH.exists():
73
- try:
74
- resource = QD_DECRYPT_SCRIPT_PATH
75
- shutil.copyfile(str(resource), str(self.QIDIAN_DECRYPT_SCRIPT_PATH))
76
- logger.info(
77
- "[decryptor] Copied decrypt script to %s",
78
- self.QIDIAN_DECRYPT_SCRIPT_PATH,
79
- )
80
- except Exception as e:
81
- logger.error("[decryptor] Failed to copy decrypt script: %s", e)
82
- raise
83
-
84
- # 3) Download the Fock JS module from Qidian CDN if missing
85
- if not self.QIDIAN_FOCK_JS_PATH.exists():
86
- from novel_downloader.utils.network import download_js_file
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
- download_js_file(
90
- self.QIDIAN_FOCK_JS_URL,
91
- self.script_dir,
92
- on_exist="overwrite",
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 | int,
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
- logger.debug(
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
- ["node", self.script_path.name, input_path.name, output_path.name],
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