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.
Files changed (137) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/download.py +14 -11
  3. novel_downloader/cli/export.py +19 -19
  4. novel_downloader/cli/ui.py +35 -8
  5. novel_downloader/config/adapter.py +216 -153
  6. novel_downloader/core/__init__.py +5 -6
  7. novel_downloader/core/archived/deqixs/fetcher.py +1 -28
  8. novel_downloader/core/downloaders/__init__.py +2 -0
  9. novel_downloader/core/downloaders/base.py +34 -85
  10. novel_downloader/core/downloaders/common.py +147 -171
  11. novel_downloader/core/downloaders/qianbi.py +30 -64
  12. novel_downloader/core/downloaders/qidian.py +157 -184
  13. novel_downloader/core/downloaders/qqbook.py +292 -0
  14. novel_downloader/core/downloaders/registry.py +2 -2
  15. novel_downloader/core/exporters/__init__.py +2 -0
  16. novel_downloader/core/exporters/base.py +37 -59
  17. novel_downloader/core/exporters/common.py +620 -0
  18. novel_downloader/core/exporters/linovelib.py +47 -0
  19. novel_downloader/core/exporters/qidian.py +41 -12
  20. novel_downloader/core/exporters/qqbook.py +28 -0
  21. novel_downloader/core/exporters/registry.py +2 -2
  22. novel_downloader/core/fetchers/__init__.py +4 -2
  23. novel_downloader/core/fetchers/aaatxt.py +2 -22
  24. novel_downloader/core/fetchers/b520.py +3 -23
  25. novel_downloader/core/fetchers/base.py +80 -105
  26. novel_downloader/core/fetchers/biquyuedu.py +2 -22
  27. novel_downloader/core/fetchers/dxmwx.py +10 -22
  28. novel_downloader/core/fetchers/esjzone.py +6 -29
  29. novel_downloader/core/fetchers/guidaye.py +2 -22
  30. novel_downloader/core/fetchers/hetushu.py +9 -29
  31. novel_downloader/core/fetchers/i25zw.py +2 -16
  32. novel_downloader/core/fetchers/ixdzs8.py +2 -16
  33. novel_downloader/core/fetchers/jpxs123.py +2 -16
  34. novel_downloader/core/fetchers/lewenn.py +2 -22
  35. novel_downloader/core/fetchers/linovelib.py +4 -20
  36. novel_downloader/core/fetchers/{eightnovel.py → n8novel.py} +12 -40
  37. novel_downloader/core/fetchers/piaotia.py +2 -16
  38. novel_downloader/core/fetchers/qbtr.py +2 -16
  39. novel_downloader/core/fetchers/qianbi.py +1 -20
  40. novel_downloader/core/fetchers/qidian.py +27 -68
  41. novel_downloader/core/fetchers/qqbook.py +177 -0
  42. novel_downloader/core/fetchers/quanben5.py +9 -29
  43. novel_downloader/core/fetchers/rate_limiter.py +22 -53
  44. novel_downloader/core/fetchers/sfacg.py +3 -16
  45. novel_downloader/core/fetchers/shencou.py +2 -16
  46. novel_downloader/core/fetchers/shuhaige.py +2 -22
  47. novel_downloader/core/fetchers/tongrenquan.py +2 -22
  48. novel_downloader/core/fetchers/ttkan.py +3 -14
  49. novel_downloader/core/fetchers/wanbengo.py +2 -22
  50. novel_downloader/core/fetchers/xiaoshuowu.py +2 -16
  51. novel_downloader/core/fetchers/xiguashuwu.py +4 -20
  52. novel_downloader/core/fetchers/xs63b.py +3 -15
  53. novel_downloader/core/fetchers/xshbook.py +2 -22
  54. novel_downloader/core/fetchers/yamibo.py +4 -28
  55. novel_downloader/core/fetchers/yibige.py +13 -26
  56. novel_downloader/core/interfaces/exporter.py +19 -7
  57. novel_downloader/core/interfaces/fetcher.py +23 -49
  58. novel_downloader/core/interfaces/parser.py +2 -2
  59. novel_downloader/core/parsers/__init__.py +4 -2
  60. novel_downloader/core/parsers/b520.py +2 -2
  61. novel_downloader/core/parsers/base.py +5 -39
  62. novel_downloader/core/parsers/esjzone.py +3 -3
  63. novel_downloader/core/parsers/{eightnovel.py → n8novel.py} +7 -7
  64. novel_downloader/core/parsers/qidian.py +717 -0
  65. novel_downloader/core/parsers/qqbook.py +709 -0
  66. novel_downloader/core/parsers/xiguashuwu.py +8 -15
  67. novel_downloader/core/searchers/__init__.py +2 -2
  68. novel_downloader/core/searchers/b520.py +1 -1
  69. novel_downloader/core/searchers/base.py +2 -2
  70. novel_downloader/core/searchers/{eightnovel.py → n8novel.py} +5 -5
  71. novel_downloader/locales/en.json +3 -3
  72. novel_downloader/locales/zh.json +3 -3
  73. novel_downloader/models/__init__.py +2 -0
  74. novel_downloader/models/book.py +1 -0
  75. novel_downloader/models/config.py +12 -0
  76. novel_downloader/resources/config/settings.toml +23 -5
  77. novel_downloader/resources/js_scripts/expr_to_json.js +14 -0
  78. novel_downloader/resources/js_scripts/qidian_decrypt_node.js +21 -16
  79. novel_downloader/resources/js_scripts/qq_decrypt_node.js +92 -0
  80. novel_downloader/utils/__init__.py +0 -2
  81. novel_downloader/utils/chapter_storage.py +2 -3
  82. novel_downloader/utils/constants.py +7 -3
  83. novel_downloader/utils/cookies.py +32 -17
  84. novel_downloader/utils/crypto_utils/__init__.py +0 -6
  85. novel_downloader/utils/crypto_utils/aes_util.py +1 -1
  86. novel_downloader/utils/crypto_utils/rc4.py +40 -50
  87. novel_downloader/utils/epub/__init__.py +2 -3
  88. novel_downloader/utils/epub/builder.py +6 -6
  89. novel_downloader/utils/epub/constants.py +1 -6
  90. novel_downloader/utils/epub/documents.py +7 -7
  91. novel_downloader/utils/epub/models.py +8 -8
  92. novel_downloader/utils/epub/utils.py +10 -10
  93. novel_downloader/utils/file_utils/io.py +48 -73
  94. novel_downloader/utils/file_utils/normalize.py +1 -7
  95. novel_downloader/utils/file_utils/sanitize.py +4 -11
  96. novel_downloader/utils/fontocr/__init__.py +13 -0
  97. novel_downloader/utils/{fontocr.py → fontocr/core.py} +72 -61
  98. novel_downloader/utils/fontocr/loader.py +52 -0
  99. novel_downloader/utils/logger.py +80 -56
  100. novel_downloader/utils/network.py +16 -40
  101. novel_downloader/utils/node_decryptor/__init__.py +13 -0
  102. novel_downloader/utils/node_decryptor/decryptor.py +342 -0
  103. novel_downloader/{core/parsers/qidian/utils → utils/node_decryptor}/decryptor_fetcher.py +5 -6
  104. novel_downloader/utils/text_utils/text_cleaner.py +39 -30
  105. novel_downloader/utils/text_utils/truncate_utils.py +3 -14
  106. novel_downloader/utils/time_utils/sleep_utils.py +53 -43
  107. novel_downloader/web/main.py +1 -1
  108. novel_downloader/web/pages/download.py +1 -1
  109. novel_downloader/web/pages/search.py +4 -4
  110. novel_downloader/web/services/task_manager.py +2 -0
  111. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/METADATA +5 -1
  112. novel_downloader-2.0.2.dist-info/RECORD +203 -0
  113. novel_downloader/core/exporters/common/__init__.py +0 -11
  114. novel_downloader/core/exporters/common/epub.py +0 -198
  115. novel_downloader/core/exporters/common/main_exporter.py +0 -64
  116. novel_downloader/core/exporters/common/txt.py +0 -146
  117. novel_downloader/core/exporters/epub_util.py +0 -215
  118. novel_downloader/core/exporters/linovelib/__init__.py +0 -11
  119. novel_downloader/core/exporters/linovelib/epub.py +0 -349
  120. novel_downloader/core/exporters/linovelib/main_exporter.py +0 -66
  121. novel_downloader/core/exporters/linovelib/txt.py +0 -139
  122. novel_downloader/core/exporters/txt_util.py +0 -67
  123. novel_downloader/core/parsers/qidian/__init__.py +0 -10
  124. novel_downloader/core/parsers/qidian/book_info_parser.py +0 -89
  125. novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -470
  126. novel_downloader/core/parsers/qidian/chapter_normal.py +0 -126
  127. novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
  128. novel_downloader/core/parsers/qidian/main_parser.py +0 -101
  129. novel_downloader/core/parsers/qidian/utils/__init__.py +0 -30
  130. novel_downloader/core/parsers/qidian/utils/fontmap_recover.py +0 -143
  131. novel_downloader/core/parsers/qidian/utils/helpers.py +0 -110
  132. novel_downloader/core/parsers/qidian/utils/node_decryptor.py +0 -175
  133. novel_downloader-2.0.0.dist-info/RECORD +0 -210
  134. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/WHEEL +0 -0
  135. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/entry_points.txt +0 -0
  136. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/licenses/LICENSE +0 -0
  137. {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.core.parsers.qidian.utils.decryptor_fetcher
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
- "ensure_decryptor",
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 ensure_decryptor(dest_root: Path | None = None) -> Path:
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
- # 版本: /<version>/<binary>
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
- :param config: TextCleanerConfig instance containing:
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
- - remove_invisible: whether to strip BOM/zero-width chars
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
- title_parts = title_remove + [re.escape(k) for k in self._title_repl_map]
77
- title_parts.sort(
78
- key=len, reverse=True
79
- ) # longer first to avoid prefix collisions
80
- title_pattern = "|".join(title_parts) if title_parts else r"$^"
81
- self._title_combined_rx: Pattern[str] = re.compile(title_pattern)
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
- content_parts = content_remove + [re.escape(k) for k in self._content_repl_map]
85
- content_parts.sort(key=len, reverse=True)
86
- content_pattern = "|".join(content_parts) if content_parts else r"$^"
87
- self._content_combined_rx: Pattern[str] = re.compile(
88
- content_pattern, flags=re.MULTILINE
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
- - U+FEFF (BOM)
136
- - U+200B ZERO WIDTH SPACE
137
- - U+200C ZERO WIDTH NON-JOINER
138
- - U+200D ZERO WIDTH JOINER
139
- - U+2060 WORD JOINER
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
- def _sub(match: Match[str]) -> str:
167
- token = match.group(0)
168
- # If token in repl_map -> replacement; else -> delete (empty string)
169
- return repl_map.get(token, "")
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 (rounded up), preserving line breaks.
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 = math.ceil(len(non_empty_lines) / 2)
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 jitter_sleep(
19
+ def _calc_sleep_duration(
20
20
  base: float,
21
- add_spread: float = 0.0,
22
- mul_spread: float = 1.0,
23
- *,
21
+ add_spread: float,
22
+ mul_spread: float,
24
23
  max_sleep: float | None = None,
25
- ) -> None:
24
+ *,
25
+ log_prefix: str = "sleep",
26
+ ) -> float | None:
26
27
  """
27
- Sleep for a random duration by combining multiplicative and additive jitter.
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
- If `max_sleep` is provided, the duration will be capped at that value.
30
+ duration = base * uniform(1.0, mul_spread) + uniform(0, add_spread)
34
31
 
35
- :param base: Base sleep time in seconds. Must be >= 0.
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
- "[sleep] Invalid parameters: base=%s, add_spread=%s, mul_spread=%s",
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("[time] Sleeping for %.2f seconds", duration)
58
- time.sleep(duration)
59
- return
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
- async def async_jitter_sleep(
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
- Async sleep for a random duration by combining multiplicative and additive jitter.
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
- if base < 0 or add_spread < 0 or mul_spread < 1.0:
84
- logger.warning(
85
- "[async sleep] Invalid parameters: base=%s, add_spread=%s, mul_spread=%s",
86
- base,
87
- add_spread,
88
- mul_spread,
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
- if max_sleep is not None:
97
- duration = min(duration, max_sleep)
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
- logger.debug("[async time] Sleeping for %.2f seconds", duration)
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)
@@ -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(log_level=log_level)
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)