novel-downloader 2.0.1__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 (104) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/download.py +11 -8
  3. novel_downloader/cli/export.py +17 -17
  4. novel_downloader/cli/ui.py +28 -1
  5. novel_downloader/config/adapter.py +27 -1
  6. novel_downloader/core/archived/deqixs/fetcher.py +1 -28
  7. novel_downloader/core/downloaders/__init__.py +2 -0
  8. novel_downloader/core/downloaders/base.py +34 -85
  9. novel_downloader/core/downloaders/common.py +147 -171
  10. novel_downloader/core/downloaders/qianbi.py +30 -64
  11. novel_downloader/core/downloaders/qidian.py +157 -184
  12. novel_downloader/core/downloaders/qqbook.py +292 -0
  13. novel_downloader/core/downloaders/registry.py +2 -2
  14. novel_downloader/core/exporters/__init__.py +2 -0
  15. novel_downloader/core/exporters/base.py +37 -59
  16. novel_downloader/core/exporters/common.py +620 -0
  17. novel_downloader/core/exporters/linovelib.py +47 -0
  18. novel_downloader/core/exporters/qidian.py +41 -12
  19. novel_downloader/core/exporters/qqbook.py +28 -0
  20. novel_downloader/core/exporters/registry.py +2 -2
  21. novel_downloader/core/fetchers/__init__.py +4 -2
  22. novel_downloader/core/fetchers/aaatxt.py +2 -22
  23. novel_downloader/core/fetchers/b520.py +3 -23
  24. novel_downloader/core/fetchers/base.py +80 -105
  25. novel_downloader/core/fetchers/biquyuedu.py +2 -22
  26. novel_downloader/core/fetchers/dxmwx.py +10 -22
  27. novel_downloader/core/fetchers/esjzone.py +6 -29
  28. novel_downloader/core/fetchers/guidaye.py +2 -22
  29. novel_downloader/core/fetchers/hetushu.py +9 -29
  30. novel_downloader/core/fetchers/i25zw.py +2 -16
  31. novel_downloader/core/fetchers/ixdzs8.py +2 -16
  32. novel_downloader/core/fetchers/jpxs123.py +2 -16
  33. novel_downloader/core/fetchers/lewenn.py +2 -22
  34. novel_downloader/core/fetchers/linovelib.py +4 -20
  35. novel_downloader/core/fetchers/{eightnovel.py → n8novel.py} +12 -40
  36. novel_downloader/core/fetchers/piaotia.py +2 -16
  37. novel_downloader/core/fetchers/qbtr.py +2 -16
  38. novel_downloader/core/fetchers/qianbi.py +1 -20
  39. novel_downloader/core/fetchers/qidian.py +7 -33
  40. novel_downloader/core/fetchers/qqbook.py +177 -0
  41. novel_downloader/core/fetchers/quanben5.py +9 -29
  42. novel_downloader/core/fetchers/rate_limiter.py +22 -53
  43. novel_downloader/core/fetchers/sfacg.py +3 -16
  44. novel_downloader/core/fetchers/shencou.py +2 -16
  45. novel_downloader/core/fetchers/shuhaige.py +2 -22
  46. novel_downloader/core/fetchers/tongrenquan.py +2 -22
  47. novel_downloader/core/fetchers/ttkan.py +3 -14
  48. novel_downloader/core/fetchers/wanbengo.py +2 -22
  49. novel_downloader/core/fetchers/xiaoshuowu.py +2 -16
  50. novel_downloader/core/fetchers/xiguashuwu.py +4 -20
  51. novel_downloader/core/fetchers/xs63b.py +3 -15
  52. novel_downloader/core/fetchers/xshbook.py +2 -22
  53. novel_downloader/core/fetchers/yamibo.py +4 -28
  54. novel_downloader/core/fetchers/yibige.py +13 -26
  55. novel_downloader/core/interfaces/exporter.py +19 -7
  56. novel_downloader/core/interfaces/fetcher.py +21 -47
  57. novel_downloader/core/parsers/__init__.py +4 -2
  58. novel_downloader/core/parsers/b520.py +2 -2
  59. novel_downloader/core/parsers/base.py +4 -39
  60. novel_downloader/core/parsers/{eightnovel.py → n8novel.py} +5 -5
  61. novel_downloader/core/parsers/{qidian/main_parser.py → qidian.py} +147 -266
  62. novel_downloader/core/parsers/qqbook.py +709 -0
  63. novel_downloader/core/parsers/xiguashuwu.py +3 -4
  64. novel_downloader/core/searchers/__init__.py +2 -2
  65. novel_downloader/core/searchers/b520.py +1 -1
  66. novel_downloader/core/searchers/base.py +2 -2
  67. novel_downloader/core/searchers/{eightnovel.py → n8novel.py} +5 -5
  68. novel_downloader/models/__init__.py +2 -0
  69. novel_downloader/models/book.py +1 -0
  70. novel_downloader/models/config.py +12 -0
  71. novel_downloader/resources/config/settings.toml +23 -5
  72. novel_downloader/resources/js_scripts/expr_to_json.js +14 -0
  73. novel_downloader/resources/js_scripts/qidian_decrypt_node.js +21 -16
  74. novel_downloader/resources/js_scripts/qq_decrypt_node.js +92 -0
  75. novel_downloader/utils/constants.py +6 -0
  76. novel_downloader/utils/crypto_utils/aes_util.py +1 -1
  77. novel_downloader/utils/epub/constants.py +1 -6
  78. novel_downloader/utils/fontocr/core.py +2 -0
  79. novel_downloader/utils/fontocr/loader.py +10 -8
  80. novel_downloader/utils/node_decryptor/__init__.py +13 -0
  81. novel_downloader/utils/node_decryptor/decryptor.py +342 -0
  82. novel_downloader/{core/parsers/qidian/utils → utils/node_decryptor}/decryptor_fetcher.py +5 -6
  83. novel_downloader/web/pages/download.py +1 -1
  84. novel_downloader/web/pages/search.py +1 -1
  85. novel_downloader/web/services/task_manager.py +2 -0
  86. {novel_downloader-2.0.1.dist-info → novel_downloader-2.0.2.dist-info}/METADATA +4 -1
  87. {novel_downloader-2.0.1.dist-info → novel_downloader-2.0.2.dist-info}/RECORD +91 -94
  88. novel_downloader/core/exporters/common/__init__.py +0 -11
  89. novel_downloader/core/exporters/common/epub.py +0 -198
  90. novel_downloader/core/exporters/common/main_exporter.py +0 -64
  91. novel_downloader/core/exporters/common/txt.py +0 -146
  92. novel_downloader/core/exporters/epub_util.py +0 -215
  93. novel_downloader/core/exporters/linovelib/__init__.py +0 -11
  94. novel_downloader/core/exporters/linovelib/epub.py +0 -349
  95. novel_downloader/core/exporters/linovelib/main_exporter.py +0 -66
  96. novel_downloader/core/exporters/linovelib/txt.py +0 -139
  97. novel_downloader/core/exporters/txt_util.py +0 -67
  98. novel_downloader/core/parsers/qidian/__init__.py +0 -10
  99. novel_downloader/core/parsers/qidian/utils/__init__.py +0 -11
  100. novel_downloader/core/parsers/qidian/utils/node_decryptor.py +0 -175
  101. {novel_downloader-2.0.1.dist-info → novel_downloader-2.0.2.dist-info}/WHEEL +0 -0
  102. {novel_downloader-2.0.1.dist-info → novel_downloader-2.0.2.dist-info}/entry_points.txt +0 -0
  103. {novel_downloader-2.0.1.dist-info → novel_downloader-2.0.2.dist-info}/licenses/LICENSE +0 -0
  104. {novel_downloader-2.0.1.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
 
@@ -15,7 +15,7 @@ _SUPPORT_SITES = {
15
15
  "biquge": "笔趣阁 (biquge)",
16
16
  "biquyuedu": "精彩小说 (biquyuedu)",
17
17
  "dxmwx": "大熊猫文学网 (dxmwx)",
18
- "eightnovel": "无限轻小说 (8novel)",
18
+ "n8novel": "无限轻小说 (n8novel)",
19
19
  "esjzone": "ESJ Zone (esjzone)",
20
20
  "guidaye": "名著阅读 (guidaye)",
21
21
  "hetushu": "和图书 (hetushu)",
@@ -26,7 +26,7 @@ _SUPPORT_SITES = {
26
26
  "aaatxt": "3A电子书",
27
27
  "biquge": "笔趣阁",
28
28
  "dxmwx": "大熊猫文学网",
29
- "eightnovel": "无限轻小说",
29
+ "n8novel": "无限轻小说",
30
30
  "esjzone": "ESJ Zone",
31
31
  "hetushu": "和图书",
32
32
  "i25zw": "25中文网",
@@ -205,6 +205,8 @@ class TaskManager:
205
205
 
206
206
  task.status = "completed"
207
207
 
208
+ exporter.close()
209
+
208
210
  except Exception as e:
209
211
  task.status = "failed"
210
212
  task.error = str(e)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: novel-downloader
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: A command-line tool for downloading Chinese web novels from Qidian and similar platforms.
5
5
  Author-email: Saudade Z <saudadez217@gmail.com>
6
6
  License: MIT License
@@ -123,6 +123,7 @@ novel-cli download 123456
123
123
 
124
124
  * 支持站点见: [支持站点列表](https://github.com/saudadez21/novel-downloader/blob/main/docs/4-supported-sites.md)
125
125
  * 更多示例见: [CLI 使用示例](https://github.com/saudadez21/novel-downloader/blob/main/docs/5-cli-usage-examples.md)
126
+ * 运行中可使用 `CTRL+C` 取消任务
126
127
 
127
128
  ### 3. 图形界面 (GUI / Web)
128
129
 
@@ -134,6 +135,8 @@ novel-web
134
135
  # novel-web --listen public
135
136
  ```
136
137
 
138
+ * 运行中可使用 `CTRL+C` 停止服务
139
+
137
140
  ---
138
141
 
139
142
  ## 从源码安装 (开发版)