novel-downloader 2.0.0__py3-none-any.whl → 2.0.2__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 +14 -11
- novel_downloader/cli/export.py +19 -19
- novel_downloader/cli/ui.py +35 -8
- novel_downloader/config/adapter.py +216 -153
- novel_downloader/core/__init__.py +5 -6
- novel_downloader/core/archived/deqixs/fetcher.py +1 -28
- novel_downloader/core/downloaders/__init__.py +2 -0
- novel_downloader/core/downloaders/base.py +34 -85
- novel_downloader/core/downloaders/common.py +147 -171
- novel_downloader/core/downloaders/qianbi.py +30 -64
- novel_downloader/core/downloaders/qidian.py +157 -184
- novel_downloader/core/downloaders/qqbook.py +292 -0
- novel_downloader/core/downloaders/registry.py +2 -2
- novel_downloader/core/exporters/__init__.py +2 -0
- novel_downloader/core/exporters/base.py +37 -59
- novel_downloader/core/exporters/common.py +620 -0
- novel_downloader/core/exporters/linovelib.py +47 -0
- novel_downloader/core/exporters/qidian.py +41 -12
- novel_downloader/core/exporters/qqbook.py +28 -0
- novel_downloader/core/exporters/registry.py +2 -2
- novel_downloader/core/fetchers/__init__.py +4 -2
- novel_downloader/core/fetchers/aaatxt.py +2 -22
- novel_downloader/core/fetchers/b520.py +3 -23
- novel_downloader/core/fetchers/base.py +80 -105
- novel_downloader/core/fetchers/biquyuedu.py +2 -22
- novel_downloader/core/fetchers/dxmwx.py +10 -22
- novel_downloader/core/fetchers/esjzone.py +6 -29
- novel_downloader/core/fetchers/guidaye.py +2 -22
- novel_downloader/core/fetchers/hetushu.py +9 -29
- novel_downloader/core/fetchers/i25zw.py +2 -16
- novel_downloader/core/fetchers/ixdzs8.py +2 -16
- novel_downloader/core/fetchers/jpxs123.py +2 -16
- novel_downloader/core/fetchers/lewenn.py +2 -22
- novel_downloader/core/fetchers/linovelib.py +4 -20
- novel_downloader/core/fetchers/{eightnovel.py → n8novel.py} +12 -40
- novel_downloader/core/fetchers/piaotia.py +2 -16
- novel_downloader/core/fetchers/qbtr.py +2 -16
- novel_downloader/core/fetchers/qianbi.py +1 -20
- novel_downloader/core/fetchers/qidian.py +27 -68
- novel_downloader/core/fetchers/qqbook.py +177 -0
- novel_downloader/core/fetchers/quanben5.py +9 -29
- novel_downloader/core/fetchers/rate_limiter.py +22 -53
- novel_downloader/core/fetchers/sfacg.py +3 -16
- novel_downloader/core/fetchers/shencou.py +2 -16
- novel_downloader/core/fetchers/shuhaige.py +2 -22
- novel_downloader/core/fetchers/tongrenquan.py +2 -22
- novel_downloader/core/fetchers/ttkan.py +3 -14
- novel_downloader/core/fetchers/wanbengo.py +2 -22
- novel_downloader/core/fetchers/xiaoshuowu.py +2 -16
- novel_downloader/core/fetchers/xiguashuwu.py +4 -20
- novel_downloader/core/fetchers/xs63b.py +3 -15
- novel_downloader/core/fetchers/xshbook.py +2 -22
- novel_downloader/core/fetchers/yamibo.py +4 -28
- novel_downloader/core/fetchers/yibige.py +13 -26
- novel_downloader/core/interfaces/exporter.py +19 -7
- novel_downloader/core/interfaces/fetcher.py +23 -49
- novel_downloader/core/interfaces/parser.py +2 -2
- novel_downloader/core/parsers/__init__.py +4 -2
- novel_downloader/core/parsers/b520.py +2 -2
- novel_downloader/core/parsers/base.py +5 -39
- novel_downloader/core/parsers/esjzone.py +3 -3
- novel_downloader/core/parsers/{eightnovel.py → n8novel.py} +7 -7
- novel_downloader/core/parsers/qidian.py +717 -0
- novel_downloader/core/parsers/qqbook.py +709 -0
- novel_downloader/core/parsers/xiguashuwu.py +8 -15
- novel_downloader/core/searchers/__init__.py +2 -2
- novel_downloader/core/searchers/b520.py +1 -1
- novel_downloader/core/searchers/base.py +2 -2
- novel_downloader/core/searchers/{eightnovel.py → n8novel.py} +5 -5
- novel_downloader/locales/en.json +3 -3
- novel_downloader/locales/zh.json +3 -3
- novel_downloader/models/__init__.py +2 -0
- novel_downloader/models/book.py +1 -0
- novel_downloader/models/config.py +12 -0
- novel_downloader/resources/config/settings.toml +23 -5
- novel_downloader/resources/js_scripts/expr_to_json.js +14 -0
- novel_downloader/resources/js_scripts/qidian_decrypt_node.js +21 -16
- novel_downloader/resources/js_scripts/qq_decrypt_node.js +92 -0
- novel_downloader/utils/__init__.py +0 -2
- novel_downloader/utils/chapter_storage.py +2 -3
- novel_downloader/utils/constants.py +7 -3
- novel_downloader/utils/cookies.py +32 -17
- novel_downloader/utils/crypto_utils/__init__.py +0 -6
- novel_downloader/utils/crypto_utils/aes_util.py +1 -1
- novel_downloader/utils/crypto_utils/rc4.py +40 -50
- novel_downloader/utils/epub/__init__.py +2 -3
- novel_downloader/utils/epub/builder.py +6 -6
- novel_downloader/utils/epub/constants.py +1 -6
- novel_downloader/utils/epub/documents.py +7 -7
- novel_downloader/utils/epub/models.py +8 -8
- novel_downloader/utils/epub/utils.py +10 -10
- novel_downloader/utils/file_utils/io.py +48 -73
- novel_downloader/utils/file_utils/normalize.py +1 -7
- novel_downloader/utils/file_utils/sanitize.py +4 -11
- novel_downloader/utils/fontocr/__init__.py +13 -0
- novel_downloader/utils/{fontocr.py → fontocr/core.py} +72 -61
- novel_downloader/utils/fontocr/loader.py +52 -0
- novel_downloader/utils/logger.py +80 -56
- novel_downloader/utils/network.py +16 -40
- novel_downloader/utils/node_decryptor/__init__.py +13 -0
- novel_downloader/utils/node_decryptor/decryptor.py +342 -0
- novel_downloader/{core/parsers/qidian/utils → utils/node_decryptor}/decryptor_fetcher.py +5 -6
- novel_downloader/utils/text_utils/text_cleaner.py +39 -30
- novel_downloader/utils/text_utils/truncate_utils.py +3 -14
- novel_downloader/utils/time_utils/sleep_utils.py +53 -43
- novel_downloader/web/main.py +1 -1
- novel_downloader/web/pages/download.py +1 -1
- novel_downloader/web/pages/search.py +4 -4
- novel_downloader/web/services/task_manager.py +2 -0
- {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/METADATA +5 -1
- novel_downloader-2.0.2.dist-info/RECORD +203 -0
- novel_downloader/core/exporters/common/__init__.py +0 -11
- novel_downloader/core/exporters/common/epub.py +0 -198
- novel_downloader/core/exporters/common/main_exporter.py +0 -64
- novel_downloader/core/exporters/common/txt.py +0 -146
- novel_downloader/core/exporters/epub_util.py +0 -215
- novel_downloader/core/exporters/linovelib/__init__.py +0 -11
- novel_downloader/core/exporters/linovelib/epub.py +0 -349
- novel_downloader/core/exporters/linovelib/main_exporter.py +0 -66
- novel_downloader/core/exporters/linovelib/txt.py +0 -139
- novel_downloader/core/exporters/txt_util.py +0 -67
- novel_downloader/core/parsers/qidian/__init__.py +0 -10
- novel_downloader/core/parsers/qidian/book_info_parser.py +0 -89
- novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -470
- novel_downloader/core/parsers/qidian/chapter_normal.py +0 -126
- novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
- novel_downloader/core/parsers/qidian/main_parser.py +0 -101
- novel_downloader/core/parsers/qidian/utils/__init__.py +0 -30
- novel_downloader/core/parsers/qidian/utils/fontmap_recover.py +0 -143
- novel_downloader/core/parsers/qidian/utils/helpers.py +0 -110
- novel_downloader/core/parsers/qidian/utils/node_decryptor.py +0 -175
- novel_downloader-2.0.0.dist-info/RECORD +0 -210
- {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/WHEEL +0 -0
- {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/entry_points.txt +0 -0
- {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,342 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.utils.node_decryptor.decryptor
|
4
|
+
-----------------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from __future__ import annotations
|
9
|
+
|
10
|
+
import json
|
11
|
+
import logging
|
12
|
+
import shutil
|
13
|
+
import subprocess
|
14
|
+
import uuid
|
15
|
+
from dataclasses import dataclass
|
16
|
+
from importlib.resources.abc import Traversable
|
17
|
+
from pathlib import Path
|
18
|
+
from typing import Any, Final
|
19
|
+
|
20
|
+
from novel_downloader.utils.constants import (
|
21
|
+
EXPR_TO_JSON_SCRIPT_PATH,
|
22
|
+
JS_SCRIPT_DIR,
|
23
|
+
QD_DECRYPT_SCRIPT_PATH,
|
24
|
+
QQ_DECRYPT_SCRIPT_PATH,
|
25
|
+
)
|
26
|
+
from novel_downloader.utils.network import download
|
27
|
+
|
28
|
+
logger = logging.getLogger(__name__)
|
29
|
+
|
30
|
+
|
31
|
+
@dataclass(frozen=True)
|
32
|
+
class RemoteAsset:
|
33
|
+
url: str
|
34
|
+
basename: str
|
35
|
+
|
36
|
+
|
37
|
+
@dataclass(frozen=True)
|
38
|
+
class LocalScript:
|
39
|
+
src: Traversable
|
40
|
+
dst_name: str
|
41
|
+
|
42
|
+
|
43
|
+
@dataclass(frozen=True)
|
44
|
+
class ProviderSpec:
|
45
|
+
name: str
|
46
|
+
decrypt_script: LocalScript
|
47
|
+
fock_asset: RemoteAsset | None = None
|
48
|
+
has_binary_fallback: bool = False
|
49
|
+
|
50
|
+
|
51
|
+
QD_SPEC: Final[ProviderSpec] = ProviderSpec(
|
52
|
+
name="qd",
|
53
|
+
decrypt_script=LocalScript(
|
54
|
+
src=QD_DECRYPT_SCRIPT_PATH,
|
55
|
+
dst_name="qidian_decrypt_node.js",
|
56
|
+
),
|
57
|
+
fock_asset=RemoteAsset(
|
58
|
+
basename="4819793b.qeooxh.js",
|
59
|
+
url="https://cococdn.qidian.com/coco/s12062024/4819793b.qeooxh.js",
|
60
|
+
),
|
61
|
+
has_binary_fallback=True,
|
62
|
+
)
|
63
|
+
|
64
|
+
QQ_SPEC: Final[ProviderSpec] = ProviderSpec(
|
65
|
+
name="qq",
|
66
|
+
decrypt_script=LocalScript(
|
67
|
+
src=QQ_DECRYPT_SCRIPT_PATH,
|
68
|
+
dst_name="qq_decrypt_node.js",
|
69
|
+
),
|
70
|
+
fock_asset=RemoteAsset(
|
71
|
+
basename="cefc2a5d.pz1phw.js",
|
72
|
+
url="https://imgservices-1252317822.image.myqcloud.com/coco/s10192022/cefc2a5d.pz1phw.js",
|
73
|
+
),
|
74
|
+
has_binary_fallback=False,
|
75
|
+
)
|
76
|
+
|
77
|
+
EVAL_SPEC: Final[LocalScript] = LocalScript(
|
78
|
+
src=EXPR_TO_JSON_SCRIPT_PATH,
|
79
|
+
dst_name="expr_to_json.js",
|
80
|
+
)
|
81
|
+
|
82
|
+
|
83
|
+
class NodeDecryptor:
|
84
|
+
"""
|
85
|
+
Decrypts chapter payloads using Node-backed scripts and/or a binary fallback.
|
86
|
+
"""
|
87
|
+
|
88
|
+
def __init__(self) -> None:
|
89
|
+
"""
|
90
|
+
Initialise the decryptor environment.
|
91
|
+
"""
|
92
|
+
self.script_dir: Path = JS_SCRIPT_DIR
|
93
|
+
self.script_dir.mkdir(parents=True, exist_ok=True)
|
94
|
+
|
95
|
+
self.has_node: bool = shutil.which("node") is not None
|
96
|
+
|
97
|
+
# Prepared commands (None => unavailable)
|
98
|
+
self._qd_script_cmd: list[str] | None = None
|
99
|
+
self._qq_script_cmd: list[str] | None = None
|
100
|
+
self._eval_script_cmd: list[str] | None = None
|
101
|
+
|
102
|
+
self._prepare_eval_environment()
|
103
|
+
self._prepare_provider(QD_SPEC) # sets _qd_script_cmd
|
104
|
+
self._prepare_provider(QQ_SPEC) # sets _qq_script_cmd
|
105
|
+
|
106
|
+
def decrypt_qd(
|
107
|
+
self,
|
108
|
+
ciphertext: str,
|
109
|
+
chapter_id: str,
|
110
|
+
fkp: str,
|
111
|
+
fuid: str,
|
112
|
+
) -> str:
|
113
|
+
"""
|
114
|
+
Qidian decrypt. Uses Node if present; otherwise tries a fallback binary.
|
115
|
+
|
116
|
+
:param ciphertext: Base64-encoded encrypted content.
|
117
|
+
:param chapter_id: The chapter's numeric ID.
|
118
|
+
:param fkp: Base64-encoded Fock key param from the page.
|
119
|
+
:param fuid: Fock user ID param from the page.
|
120
|
+
:return: "" if unavailable or inputs are missing.
|
121
|
+
:raises RuntimeError: if the Node.js subprocess exits with a non-zero code.
|
122
|
+
"""
|
123
|
+
if not self._qd_script_cmd:
|
124
|
+
logger.warning("QD decryptor unavailable (no Node and no fallback).")
|
125
|
+
return ""
|
126
|
+
if not (ciphertext and chapter_id and fkp and fuid):
|
127
|
+
logger.debug("QD decrypt: missing required inputs.")
|
128
|
+
return ""
|
129
|
+
|
130
|
+
task_id = uuid.uuid4().hex
|
131
|
+
input_path = self.script_dir / f"qd_in_{task_id}.json"
|
132
|
+
output_path = self.script_dir / f"qd_out_{task_id}.txt"
|
133
|
+
|
134
|
+
try:
|
135
|
+
input_path.write_text(
|
136
|
+
json.dumps([ciphertext, chapter_id, fkp, fuid]),
|
137
|
+
encoding="utf-8",
|
138
|
+
)
|
139
|
+
|
140
|
+
cmd = self._qd_script_cmd + [input_path.name, output_path.name]
|
141
|
+
logger.debug("Running QD decrypt: %s (cwd=%s)", cmd, self.script_dir)
|
142
|
+
proc = subprocess.run(
|
143
|
+
cmd,
|
144
|
+
capture_output=True,
|
145
|
+
text=True,
|
146
|
+
cwd=self.script_dir,
|
147
|
+
)
|
148
|
+
|
149
|
+
if proc.returncode != 0:
|
150
|
+
stderr = (proc.stderr or "").strip()
|
151
|
+
raise RuntimeError(
|
152
|
+
f"QD decrypt failed: {stderr or 'non-zero exit code'}"
|
153
|
+
)
|
154
|
+
|
155
|
+
return output_path.read_text(encoding="utf-8").strip()
|
156
|
+
finally:
|
157
|
+
input_path.unlink(missing_ok=True)
|
158
|
+
output_path.unlink(missing_ok=True)
|
159
|
+
|
160
|
+
def decrypt_qq(
|
161
|
+
self,
|
162
|
+
ciphertext: str,
|
163
|
+
chapter_id: str,
|
164
|
+
fkp: str,
|
165
|
+
fuid: str,
|
166
|
+
) -> str:
|
167
|
+
"""
|
168
|
+
QQ decrypt. Node-only.
|
169
|
+
|
170
|
+
:param ciphertext: Base64-encoded encrypted content.
|
171
|
+
:param chapter_id: The chapter's numeric ID.
|
172
|
+
:param fkp: Base64-encoded Fock key param from the page.
|
173
|
+
:param fuid: Fock user ID param from the page.
|
174
|
+
:return: "" if Node/script not available or inputs missing.
|
175
|
+
:raises RuntimeError: if the Node.js subprocess exits with a non-zero code.
|
176
|
+
"""
|
177
|
+
if not self._qq_script_cmd:
|
178
|
+
logger.info(
|
179
|
+
"QQ decrypt skipped: Node not available or script not prepared."
|
180
|
+
)
|
181
|
+
return ""
|
182
|
+
if not (ciphertext and chapter_id and fkp and fuid):
|
183
|
+
logger.debug("QQ decrypt: missing required inputs.")
|
184
|
+
return ""
|
185
|
+
|
186
|
+
task_id = uuid.uuid4().hex
|
187
|
+
input_path = self.script_dir / f"qq_in_{task_id}.json"
|
188
|
+
output_path = self.script_dir / f"qq_out_{task_id}.txt"
|
189
|
+
|
190
|
+
try:
|
191
|
+
input_path.write_text(
|
192
|
+
json.dumps([ciphertext, chapter_id, fkp, fuid]),
|
193
|
+
encoding="utf-8",
|
194
|
+
)
|
195
|
+
|
196
|
+
cmd = self._qq_script_cmd + [input_path.name, output_path.name]
|
197
|
+
logger.debug("Running QQ decrypt: %s (cwd=%s)", cmd, self.script_dir)
|
198
|
+
proc = subprocess.run(
|
199
|
+
cmd,
|
200
|
+
capture_output=True,
|
201
|
+
text=True,
|
202
|
+
cwd=self.script_dir,
|
203
|
+
)
|
204
|
+
|
205
|
+
if proc.returncode != 0:
|
206
|
+
stderr = (proc.stderr or "").strip()
|
207
|
+
raise RuntimeError(
|
208
|
+
f"QQ decrypt failed: {stderr or 'non-zero exit code'}"
|
209
|
+
)
|
210
|
+
|
211
|
+
return output_path.read_text(encoding="utf-8").strip()
|
212
|
+
finally:
|
213
|
+
input_path.unlink(missing_ok=True)
|
214
|
+
output_path.unlink(missing_ok=True)
|
215
|
+
|
216
|
+
def eval_to_json(self, js_code: str) -> dict[str, Any]:
|
217
|
+
"""
|
218
|
+
Evaluate JS and parse JSON result. Node-only.
|
219
|
+
|
220
|
+
:return: {} if unavailable or input empty.
|
221
|
+
:raises RuntimeError: if the invoked process fails or outputs invalid JSON.
|
222
|
+
"""
|
223
|
+
if not self._eval_script_cmd:
|
224
|
+
logger.info(
|
225
|
+
"eval_to_json skipped: Node not available or script not prepared."
|
226
|
+
)
|
227
|
+
return {}
|
228
|
+
if not js_code:
|
229
|
+
logger.debug("eval_to_json: empty input.")
|
230
|
+
return {}
|
231
|
+
|
232
|
+
logger.debug("Running eval_to_json (cwd=%s)", self.script_dir)
|
233
|
+
proc = subprocess.run(
|
234
|
+
self._eval_script_cmd,
|
235
|
+
input=js_code,
|
236
|
+
capture_output=True,
|
237
|
+
text=True,
|
238
|
+
encoding="utf-8",
|
239
|
+
cwd=self.script_dir,
|
240
|
+
)
|
241
|
+
if proc.returncode != 0:
|
242
|
+
stderr = (proc.stderr or "").strip()
|
243
|
+
raise RuntimeError(f"eval_to_json failed: {stderr or 'non-zero exit code'}")
|
244
|
+
|
245
|
+
stdout = (proc.stdout or "").strip()
|
246
|
+
if not stdout:
|
247
|
+
return {}
|
248
|
+
|
249
|
+
try:
|
250
|
+
return json.loads(stdout) or {}
|
251
|
+
except json.JSONDecodeError as exc:
|
252
|
+
logger.error("eval_to_json: invalid JSON output: %s", stdout)
|
253
|
+
raise RuntimeError("eval_to_json produced invalid JSON.") from exc
|
254
|
+
|
255
|
+
def _prepare_provider(self, spec: ProviderSpec) -> None:
|
256
|
+
"""
|
257
|
+
Prepare a provider:
|
258
|
+
* If Node is available: copy node script and fetch Fock asset
|
259
|
+
* Else if provider allows binary fallback (QD): try local binary
|
260
|
+
* Else: leave command None (feature disabled)
|
261
|
+
"""
|
262
|
+
dst_script = self.script_dir / spec.decrypt_script.dst_name
|
263
|
+
|
264
|
+
if self.has_node:
|
265
|
+
# Prepare Node script + assets
|
266
|
+
try:
|
267
|
+
if not dst_script.exists():
|
268
|
+
self._copy_from_traversable(spec.decrypt_script.src, dst_script)
|
269
|
+
|
270
|
+
if spec.fock_asset:
|
271
|
+
fock_path = self.script_dir / spec.fock_asset.basename
|
272
|
+
if not fock_path.exists():
|
273
|
+
download(spec.fock_asset.url, self.script_dir)
|
274
|
+
|
275
|
+
cmd: list[str] | None = ["node", str(dst_script)]
|
276
|
+
logger.debug("%s decryptor prepared with Node.", spec.name.upper())
|
277
|
+
except Exception as exc:
|
278
|
+
logger.warning("%s Node prep failed: %s", spec.name.upper(), exc)
|
279
|
+
cmd = None
|
280
|
+
else:
|
281
|
+
# No Node available
|
282
|
+
if spec.has_binary_fallback and spec.name == "qd":
|
283
|
+
try:
|
284
|
+
from .decryptor_fetcher import ensure_qd_decryptor
|
285
|
+
|
286
|
+
bin_path = ensure_qd_decryptor(self.script_dir)
|
287
|
+
cmd = [str(bin_path)]
|
288
|
+
logger.debug(
|
289
|
+
"QD decryptor prepared with binary fallback at %s.", bin_path
|
290
|
+
)
|
291
|
+
except Exception as exc:
|
292
|
+
logger.error("QD binary fallback unavailable: %s", exc)
|
293
|
+
cmd = None
|
294
|
+
else:
|
295
|
+
logger.info(
|
296
|
+
"%s decryptor skipped: Node not available.", spec.name.upper()
|
297
|
+
)
|
298
|
+
cmd = None
|
299
|
+
|
300
|
+
if spec.name == "qd":
|
301
|
+
self._qd_script_cmd = cmd
|
302
|
+
else:
|
303
|
+
self._qq_script_cmd = cmd
|
304
|
+
|
305
|
+
def _prepare_eval_environment(self) -> None:
|
306
|
+
"""
|
307
|
+
Prepare eval script (Node-only).
|
308
|
+
"""
|
309
|
+
if not self.has_node:
|
310
|
+
logger.info("eval_to_json skipped: Node not available.")
|
311
|
+
self._eval_script_cmd = None
|
312
|
+
return
|
313
|
+
|
314
|
+
dst = self.script_dir / EVAL_SPEC.dst_name
|
315
|
+
try:
|
316
|
+
if not dst.exists():
|
317
|
+
self._copy_from_traversable(EVAL_SPEC.src, dst)
|
318
|
+
self._eval_script_cmd = ["node", str(dst)]
|
319
|
+
logger.debug("eval_to_json prepared with Node.")
|
320
|
+
except Exception as exc:
|
321
|
+
logger.warning("eval_to_json prep failed; feature disabled. (%s)", exc)
|
322
|
+
self._eval_script_cmd = None
|
323
|
+
|
324
|
+
@staticmethod
|
325
|
+
def _copy_from_traversable(src: Traversable, dst: Path) -> None:
|
326
|
+
"""Copy a packaged resource (Traversable) to a filesystem path."""
|
327
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
328
|
+
with src.open("rb") as rf, open(dst, "wb") as wf:
|
329
|
+
shutil.copyfileobj(rf, wf)
|
330
|
+
|
331
|
+
|
332
|
+
_DECRYPTOR: NodeDecryptor | None = None
|
333
|
+
|
334
|
+
|
335
|
+
def get_decryptor() -> NodeDecryptor:
|
336
|
+
"""
|
337
|
+
Return the singleton NodeDecryptor.
|
338
|
+
"""
|
339
|
+
global _DECRYPTOR
|
340
|
+
if _DECRYPTOR is None:
|
341
|
+
_DECRYPTOR = NodeDecryptor()
|
342
|
+
return _DECRYPTOR
|
@@ -1,7 +1,7 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
novel_downloader.
|
4
|
-
|
3
|
+
novel_downloader.utils.node_decryptor.decryptor_fetcher
|
4
|
+
-------------------------------------------------------
|
5
5
|
|
6
6
|
Download and cache the *qidian-decryptor* executable from the project's
|
7
7
|
GitHub releases.
|
@@ -10,7 +10,7 @@ GitHub releases.
|
|
10
10
|
from __future__ import annotations
|
11
11
|
|
12
12
|
__all__ = [
|
13
|
-
"
|
13
|
+
"ensure_qd_decryptor",
|
14
14
|
"RELEASE_VERSION",
|
15
15
|
]
|
16
16
|
|
@@ -41,13 +41,12 @@ PLATFORM_BINARIES: Final[dict[str, str]] = {
|
|
41
41
|
# --------------------------------------------------------------------------- #
|
42
42
|
|
43
43
|
|
44
|
-
def
|
44
|
+
def ensure_qd_decryptor(dest_root: Path | None = None) -> Path:
|
45
45
|
"""
|
46
46
|
Ensure that the decryptor executable matching the current platform and
|
47
47
|
:data:`RELEASE_VERSION` exists locally; download it if necessary.
|
48
48
|
|
49
49
|
:param dest_root: Root directory used to cache the binary.
|
50
|
-
If *None*, the global constant ``JS_SCRIPT_DIR`` is used.
|
51
50
|
:return: Path to the ready-to-use executable (inside the version sub-folder).
|
52
51
|
:raises RuntimeError: If the current platform is unsupported.
|
53
52
|
:raises ValueError: If the downloaded file fails SHA-256 verification.
|
@@ -56,7 +55,7 @@ def ensure_decryptor(dest_root: Path | None = None) -> Path:
|
|
56
55
|
platform_key = _get_platform_key()
|
57
56
|
|
58
57
|
bin_name = PLATFORM_BINARIES[platform_key]
|
59
|
-
#
|
58
|
+
# /<version>/<binary>
|
60
59
|
version_dir = dest_root / RELEASE_VERSION.lstrip("v")
|
61
60
|
dest_path = version_dir / bin_name
|
62
61
|
|
@@ -42,7 +42,7 @@ class TextCleaner(Cleaner):
|
|
42
42
|
TextCleaner removes invisible characters, strips unwanted patterns,
|
43
43
|
and applies literal replacements in a single pass using a combined regex.
|
44
44
|
|
45
|
-
For regex that never matches, reference:
|
45
|
+
For regex that never matches (r"$^"), reference:
|
46
46
|
|
47
47
|
https://stackoverflow.com/questions/2930182/regex-to-not-match-anything
|
48
48
|
"""
|
@@ -53,13 +53,14 @@ class TextCleaner(Cleaner):
|
|
53
53
|
"""
|
54
54
|
Initialize TextCleaner with the given configuration.
|
55
55
|
|
56
|
-
|
56
|
+
Configuration fields (from ``TextCleanerConfig``):
|
57
|
+
* remove_invisible: whether to strip BOM/zero-width chars
|
58
|
+
* title_remove_patterns: list of regex patterns to delete from titles
|
59
|
+
* content_remove_patterns: list of regex patterns to delete from content
|
60
|
+
* title_replacements: dict of literal replacements for titles
|
61
|
+
* content_replacements: dict of literal replacements for content
|
57
62
|
|
58
|
-
|
59
|
-
- title_remove_patterns: list of regex patterns to delete from titles
|
60
|
-
- content_remove_patterns: list of regex patterns to delete from content
|
61
|
-
- title_replacements: dict of literal replacements for titles
|
62
|
-
- content_replacements: dict of literal replacements for content
|
63
|
+
:param config: A ``TextCleanerConfig`` instance.
|
63
64
|
"""
|
64
65
|
self._remove_invisible = config.remove_invisible
|
65
66
|
|
@@ -73,20 +74,23 @@ class TextCleaner(Cleaner):
|
|
73
74
|
|
74
75
|
# Build a single combined regex for title:
|
75
76
|
# all delete‐patterns OR all escaped replacement‐keys
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
77
|
+
self._title_combined_rx: re.Pattern[str] | None = None
|
78
|
+
if title_remove or self._title_repl_map:
|
79
|
+
title_parts = title_remove + [re.escape(k) for k in self._title_repl_map]
|
80
|
+
# longer first to avoid prefix collisions
|
81
|
+
title_parts.sort(key=len, reverse=True)
|
82
|
+
self._title_combined_rx = re.compile("|".join(title_parts))
|
82
83
|
|
83
84
|
# Build a single combined regex for content (multiline mode)
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
85
|
+
self._content_combined_rx: re.Pattern[str] | None = None
|
86
|
+
if content_remove or self._content_repl_map:
|
87
|
+
content_parts = content_remove + [
|
88
|
+
re.escape(k) for k in self._content_repl_map
|
89
|
+
]
|
90
|
+
content_parts.sort(key=len, reverse=True)
|
91
|
+
self._content_combined_rx = re.compile(
|
92
|
+
"|".join(content_parts), flags=re.MULTILINE
|
93
|
+
)
|
90
94
|
|
91
95
|
def clean_title(self, text: str) -> str:
|
92
96
|
"""
|
@@ -132,11 +136,11 @@ class TextCleaner(Cleaner):
|
|
132
136
|
Remove BOM and zero-width/invisible characters from the text.
|
133
137
|
|
134
138
|
Matches:
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
139
|
+
* U+FEFF (BOM)
|
140
|
+
* U+200B ZERO WIDTH SPACE
|
141
|
+
* U+200C ZERO WIDTH NON-JOINER
|
142
|
+
* U+200D ZERO WIDTH JOINER
|
143
|
+
* U+2060 WORD JOINER
|
140
144
|
|
141
145
|
:param text: Input string possibly containing invisible chars.
|
142
146
|
:return: String with those characters stripped.
|
@@ -146,7 +150,7 @@ class TextCleaner(Cleaner):
|
|
146
150
|
def _do_clean(
|
147
151
|
self,
|
148
152
|
text: str,
|
149
|
-
combined_rx: Pattern[str],
|
153
|
+
combined_rx: Pattern[str] | None,
|
150
154
|
repl_map: dict[str, str],
|
151
155
|
) -> str:
|
152
156
|
"""
|
@@ -158,17 +162,22 @@ class TextCleaner(Cleaner):
|
|
158
162
|
:param repl_map: Mapping from matched token to replacement text.
|
159
163
|
:return: Cleaned text.
|
160
164
|
"""
|
165
|
+
if not self._remove_invisible and not combined_rx:
|
166
|
+
return text.strip()
|
167
|
+
|
161
168
|
# Strip invisible chars if configured
|
162
169
|
if self._remove_invisible:
|
163
170
|
text = self._remove_bom_and_invisible(text)
|
164
171
|
|
165
172
|
# Single‐pass removal & replacement
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
173
|
+
if combined_rx:
|
174
|
+
|
175
|
+
def _sub(match: Match[str]) -> str:
|
176
|
+
# If token in repl_map -> replacement; else -> delete (empty string)
|
177
|
+
return repl_map.get(match.group(0), "")
|
178
|
+
|
179
|
+
text = combined_rx.sub(_sub, text)
|
170
180
|
|
171
|
-
text = combined_rx.sub(_sub, text)
|
172
181
|
return text.strip()
|
173
182
|
|
174
183
|
|
@@ -11,8 +11,6 @@ __all__ = [
|
|
11
11
|
"truncate_half_lines",
|
12
12
|
]
|
13
13
|
|
14
|
-
import math
|
15
|
-
|
16
14
|
|
17
15
|
def content_prefix(
|
18
16
|
text: str,
|
@@ -41,22 +39,13 @@ def content_prefix(
|
|
41
39
|
|
42
40
|
def truncate_half_lines(text: str) -> str:
|
43
41
|
"""
|
44
|
-
Keep the first half of the lines
|
42
|
+
Keep the first half of the lines.
|
45
43
|
|
46
44
|
:param text: Full input text
|
47
45
|
:return: Truncated text with first half of lines
|
48
46
|
"""
|
49
47
|
lines = text.splitlines()
|
50
48
|
non_empty_lines = [line for line in lines if line.strip()]
|
51
|
-
keep_count =
|
52
|
-
|
53
|
-
result_lines = []
|
54
|
-
count = 0
|
55
|
-
for line in lines:
|
56
|
-
result_lines.append(line)
|
57
|
-
if line.strip():
|
58
|
-
count += 1
|
59
|
-
if count >= keep_count:
|
60
|
-
break
|
61
|
-
|
49
|
+
keep_count = (len(non_empty_lines) + 1) // 2
|
50
|
+
result_lines = non_empty_lines[:keep_count]
|
62
51
|
return "\n".join(result_lines)
|
@@ -16,50 +16,51 @@ import time
|
|
16
16
|
logger = logging.getLogger(__name__)
|
17
17
|
|
18
18
|
|
19
|
-
def
|
19
|
+
def _calc_sleep_duration(
|
20
20
|
base: float,
|
21
|
-
add_spread: float
|
22
|
-
mul_spread: float
|
23
|
-
*,
|
21
|
+
add_spread: float,
|
22
|
+
mul_spread: float,
|
24
23
|
max_sleep: float | None = None,
|
25
|
-
|
24
|
+
*,
|
25
|
+
log_prefix: str = "sleep",
|
26
|
+
) -> float | None:
|
26
27
|
"""
|
27
|
-
|
28
|
-
|
29
|
-
The total sleep time is computed as:
|
30
|
-
|
31
|
-
duration = base * uniform(1.0, mul_spread) + uniform(0, add_spread)
|
28
|
+
Compute the jittered sleep duration (in seconds) or return None if params invalid.
|
32
29
|
|
33
|
-
|
30
|
+
duration = base * uniform(1.0, mul_spread) + uniform(0, add_spread)
|
34
31
|
|
35
|
-
|
36
|
-
:param add_spread: Maximum extra seconds to add after scaling base.
|
37
|
-
:param mul_spread: Maximum multiplier factor for base; drawn from [1.0, mul_spread].
|
38
|
-
:param max_sleep: Optional upper limit for the final sleep duration.
|
32
|
+
then optionally capped by max_sleep.
|
39
33
|
"""
|
40
34
|
if base < 0 or add_spread < 0 or mul_spread < 1.0:
|
41
35
|
logger.warning(
|
42
|
-
"[
|
36
|
+
"[%s] Invalid parameters: base=%s, add_spread=%s, mul_spread=%s",
|
37
|
+
log_prefix,
|
43
38
|
base,
|
44
39
|
add_spread,
|
45
40
|
mul_spread,
|
46
41
|
)
|
47
|
-
return
|
42
|
+
return None
|
48
43
|
|
49
|
-
# Calculate the raw duration
|
50
44
|
multiplicative_jitter = random.uniform(1.0, mul_spread)
|
51
|
-
additive_jitter = random.uniform(0, add_spread)
|
45
|
+
additive_jitter = random.uniform(0.0, add_spread)
|
52
46
|
duration = base * multiplicative_jitter + additive_jitter
|
53
47
|
|
54
48
|
if max_sleep is not None:
|
55
49
|
duration = min(duration, max_sleep)
|
56
50
|
|
57
|
-
logger.debug(
|
58
|
-
|
59
|
-
|
51
|
+
logger.debug(
|
52
|
+
"[%s] base=%.3f mul=%.3f add=%.3f max=%s -> duration=%.3f",
|
53
|
+
log_prefix,
|
54
|
+
base,
|
55
|
+
multiplicative_jitter,
|
56
|
+
additive_jitter,
|
57
|
+
max_sleep,
|
58
|
+
duration,
|
59
|
+
)
|
60
|
+
return duration
|
60
61
|
|
61
62
|
|
62
|
-
|
63
|
+
def jitter_sleep(
|
63
64
|
base: float,
|
64
65
|
add_spread: float = 0.0,
|
65
66
|
mul_spread: float = 1.0,
|
@@ -67,34 +68,43 @@ async def async_jitter_sleep(
|
|
67
68
|
max_sleep: float | None = None,
|
68
69
|
) -> None:
|
69
70
|
"""
|
70
|
-
|
71
|
-
|
72
|
-
The total sleep time is computed as:
|
73
|
-
|
74
|
-
duration = base * uniform(1.0, mul_spread) + uniform(0, add_spread)
|
75
|
-
|
76
|
-
If `max_sleep` is provided, the duration will be capped at that value.
|
71
|
+
Sleep for a random duration by combining multiplicative and additive jitter.
|
77
72
|
|
78
73
|
:param base: Base sleep time in seconds. Must be >= 0.
|
79
74
|
:param add_spread: Maximum extra seconds to add after scaling base.
|
80
75
|
:param mul_spread: Maximum multiplier factor for base; drawn from [1.0, mul_spread].
|
81
76
|
:param max_sleep: Optional upper limit for the final sleep duration.
|
82
77
|
"""
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
78
|
+
duration = _calc_sleep_duration(
|
79
|
+
base,
|
80
|
+
add_spread,
|
81
|
+
mul_spread,
|
82
|
+
max_sleep,
|
83
|
+
log_prefix="sleep",
|
84
|
+
)
|
85
|
+
if duration is None:
|
90
86
|
return
|
87
|
+
time.sleep(duration)
|
91
88
|
|
92
|
-
multiplicative_jitter = random.uniform(1.0, mul_spread)
|
93
|
-
additive_jitter = random.uniform(0, add_spread)
|
94
|
-
duration = base * multiplicative_jitter + additive_jitter
|
95
89
|
|
96
|
-
|
97
|
-
|
90
|
+
async def async_jitter_sleep(
|
91
|
+
base: float,
|
92
|
+
add_spread: float = 0.0,
|
93
|
+
mul_spread: float = 1.0,
|
94
|
+
*,
|
95
|
+
max_sleep: float | None = None,
|
96
|
+
) -> None:
|
97
|
+
"""
|
98
|
+
Async sleep for a random duration by combining multiplicative and additive jitter.
|
98
99
|
|
99
|
-
|
100
|
+
:param base: Base sleep time in seconds. Must be >= 0.
|
101
|
+
:param add_spread: Maximum extra seconds to add after scaling base.
|
102
|
+
:param mul_spread: Maximum multiplier factor for base; drawn from [1.0, mul_spread].
|
103
|
+
:param max_sleep: Optional upper limit for the final sleep duration.
|
104
|
+
"""
|
105
|
+
duration = _calc_sleep_duration(
|
106
|
+
base, add_spread, mul_spread, max_sleep, log_prefix="async sleep"
|
107
|
+
)
|
108
|
+
if duration is None:
|
109
|
+
return
|
100
110
|
await asyncio.sleep(duration)
|
novel_downloader/web/main.py
CHANGED
@@ -56,7 +56,7 @@ def web_main() -> None:
|
|
56
56
|
host = "127.0.0.1" if args.listen == "local" else "0.0.0.0"
|
57
57
|
|
58
58
|
log_level = get_config_value(["general", "debug", "log_level"], "INFO")
|
59
|
-
setup_logging(
|
59
|
+
setup_logging(console_level=log_level)
|
60
60
|
|
61
61
|
app.on_startup(mount_exports)
|
62
62
|
ui.run(host=host, port=args.port, reload=args.reload)
|