novel-downloader 1.5.0__py3-none-any.whl → 2.0.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 (241) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/__init__.py +1 -3
  3. novel_downloader/cli/clean.py +21 -88
  4. novel_downloader/cli/config.py +26 -21
  5. novel_downloader/cli/download.py +77 -64
  6. novel_downloader/cli/export.py +16 -20
  7. novel_downloader/cli/main.py +1 -1
  8. novel_downloader/cli/search.py +62 -65
  9. novel_downloader/cli/ui.py +156 -0
  10. novel_downloader/config/__init__.py +8 -5
  11. novel_downloader/config/adapter.py +65 -105
  12. novel_downloader/config/{loader.py → file_io.py} +53 -26
  13. novel_downloader/core/__init__.py +1 -0
  14. novel_downloader/core/archived/deqixs/fetcher.py +115 -0
  15. novel_downloader/core/archived/deqixs/parser.py +132 -0
  16. novel_downloader/core/archived/deqixs/searcher.py +89 -0
  17. novel_downloader/core/{searchers/qidian.py → archived/qidian/searcher.py} +12 -20
  18. novel_downloader/core/archived/wanbengo/searcher.py +98 -0
  19. novel_downloader/core/archived/xshbook/searcher.py +93 -0
  20. novel_downloader/core/downloaders/__init__.py +3 -24
  21. novel_downloader/core/downloaders/base.py +49 -23
  22. novel_downloader/core/downloaders/common.py +191 -137
  23. novel_downloader/core/downloaders/qianbi.py +187 -146
  24. novel_downloader/core/downloaders/qidian.py +187 -141
  25. novel_downloader/core/downloaders/registry.py +4 -2
  26. novel_downloader/core/downloaders/signals.py +46 -0
  27. novel_downloader/core/exporters/__init__.py +3 -20
  28. novel_downloader/core/exporters/base.py +33 -37
  29. novel_downloader/core/exporters/common/__init__.py +1 -2
  30. novel_downloader/core/exporters/common/epub.py +15 -10
  31. novel_downloader/core/exporters/common/main_exporter.py +19 -12
  32. novel_downloader/core/exporters/common/txt.py +14 -9
  33. novel_downloader/core/exporters/epub_util.py +59 -29
  34. novel_downloader/core/exporters/linovelib/__init__.py +1 -0
  35. novel_downloader/core/exporters/linovelib/epub.py +23 -25
  36. novel_downloader/core/exporters/linovelib/main_exporter.py +8 -12
  37. novel_downloader/core/exporters/linovelib/txt.py +17 -11
  38. novel_downloader/core/exporters/qidian.py +2 -8
  39. novel_downloader/core/exporters/registry.py +4 -2
  40. novel_downloader/core/exporters/txt_util.py +7 -7
  41. novel_downloader/core/fetchers/__init__.py +54 -48
  42. novel_downloader/core/fetchers/aaatxt.py +83 -0
  43. novel_downloader/core/fetchers/{biquge/session.py → b520.py} +6 -11
  44. novel_downloader/core/fetchers/{base/session.py → base.py} +37 -46
  45. novel_downloader/core/fetchers/{biquge/browser.py → biquyuedu.py} +12 -17
  46. novel_downloader/core/fetchers/dxmwx.py +110 -0
  47. novel_downloader/core/fetchers/eightnovel.py +139 -0
  48. novel_downloader/core/fetchers/{esjzone/session.py → esjzone.py} +19 -12
  49. novel_downloader/core/fetchers/guidaye.py +85 -0
  50. novel_downloader/core/fetchers/hetushu.py +92 -0
  51. novel_downloader/core/fetchers/{qianbi/browser.py → i25zw.py} +19 -28
  52. novel_downloader/core/fetchers/ixdzs8.py +113 -0
  53. novel_downloader/core/fetchers/jpxs123.py +101 -0
  54. novel_downloader/core/fetchers/lewenn.py +83 -0
  55. novel_downloader/core/fetchers/{linovelib/session.py → linovelib.py} +12 -13
  56. novel_downloader/core/fetchers/piaotia.py +105 -0
  57. novel_downloader/core/fetchers/qbtr.py +101 -0
  58. novel_downloader/core/fetchers/{qianbi/session.py → qianbi.py} +5 -10
  59. novel_downloader/core/fetchers/{qidian/session.py → qidian.py} +46 -39
  60. novel_downloader/core/fetchers/quanben5.py +92 -0
  61. novel_downloader/core/fetchers/{base/rate_limiter.py → rate_limiter.py} +2 -2
  62. novel_downloader/core/fetchers/registry.py +5 -16
  63. novel_downloader/core/fetchers/{sfacg/session.py → sfacg.py} +7 -10
  64. novel_downloader/core/fetchers/shencou.py +106 -0
  65. novel_downloader/core/fetchers/shuhaige.py +84 -0
  66. novel_downloader/core/fetchers/tongrenquan.py +84 -0
  67. novel_downloader/core/fetchers/ttkan.py +95 -0
  68. novel_downloader/core/fetchers/wanbengo.py +83 -0
  69. novel_downloader/core/fetchers/xiaoshuowu.py +106 -0
  70. novel_downloader/core/fetchers/xiguashuwu.py +177 -0
  71. novel_downloader/core/fetchers/xs63b.py +171 -0
  72. novel_downloader/core/fetchers/xshbook.py +85 -0
  73. novel_downloader/core/fetchers/{yamibo/session.py → yamibo.py} +19 -12
  74. novel_downloader/core/fetchers/yibige.py +114 -0
  75. novel_downloader/core/interfaces/__init__.py +1 -9
  76. novel_downloader/core/interfaces/downloader.py +6 -2
  77. novel_downloader/core/interfaces/exporter.py +7 -7
  78. novel_downloader/core/interfaces/fetcher.py +4 -17
  79. novel_downloader/core/interfaces/parser.py +5 -6
  80. novel_downloader/core/interfaces/searcher.py +9 -1
  81. novel_downloader/core/parsers/__init__.py +49 -12
  82. novel_downloader/core/parsers/aaatxt.py +132 -0
  83. novel_downloader/core/parsers/b520.py +116 -0
  84. novel_downloader/core/parsers/base.py +63 -12
  85. novel_downloader/core/parsers/biquyuedu.py +133 -0
  86. novel_downloader/core/parsers/dxmwx.py +162 -0
  87. novel_downloader/core/parsers/eightnovel.py +224 -0
  88. novel_downloader/core/parsers/esjzone.py +61 -66
  89. novel_downloader/core/parsers/guidaye.py +128 -0
  90. novel_downloader/core/parsers/hetushu.py +139 -0
  91. novel_downloader/core/parsers/i25zw.py +137 -0
  92. novel_downloader/core/parsers/ixdzs8.py +186 -0
  93. novel_downloader/core/parsers/jpxs123.py +137 -0
  94. novel_downloader/core/parsers/lewenn.py +142 -0
  95. novel_downloader/core/parsers/linovelib.py +48 -64
  96. novel_downloader/core/parsers/piaotia.py +189 -0
  97. novel_downloader/core/parsers/qbtr.py +136 -0
  98. novel_downloader/core/parsers/qianbi.py +48 -50
  99. novel_downloader/core/parsers/qidian/book_info_parser.py +58 -59
  100. novel_downloader/core/parsers/qidian/chapter_encrypted.py +272 -330
  101. novel_downloader/core/parsers/qidian/chapter_normal.py +24 -55
  102. novel_downloader/core/parsers/qidian/main_parser.py +11 -38
  103. novel_downloader/core/parsers/qidian/utils/__init__.py +1 -0
  104. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +1 -1
  105. novel_downloader/core/parsers/qidian/utils/fontmap_recover.py +143 -0
  106. novel_downloader/core/parsers/qidian/utils/helpers.py +0 -4
  107. novel_downloader/core/parsers/quanben5.py +103 -0
  108. novel_downloader/core/parsers/registry.py +5 -16
  109. novel_downloader/core/parsers/sfacg.py +38 -45
  110. novel_downloader/core/parsers/shencou.py +215 -0
  111. novel_downloader/core/parsers/shuhaige.py +111 -0
  112. novel_downloader/core/parsers/tongrenquan.py +116 -0
  113. novel_downloader/core/parsers/ttkan.py +132 -0
  114. novel_downloader/core/parsers/wanbengo.py +191 -0
  115. novel_downloader/core/parsers/xiaoshuowu.py +173 -0
  116. novel_downloader/core/parsers/xiguashuwu.py +435 -0
  117. novel_downloader/core/parsers/xs63b.py +161 -0
  118. novel_downloader/core/parsers/xshbook.py +134 -0
  119. novel_downloader/core/parsers/yamibo.py +87 -131
  120. novel_downloader/core/parsers/yibige.py +166 -0
  121. novel_downloader/core/searchers/__init__.py +34 -3
  122. novel_downloader/core/searchers/aaatxt.py +107 -0
  123. novel_downloader/core/searchers/{biquge.py → b520.py} +29 -28
  124. novel_downloader/core/searchers/base.py +112 -36
  125. novel_downloader/core/searchers/dxmwx.py +105 -0
  126. novel_downloader/core/searchers/eightnovel.py +84 -0
  127. novel_downloader/core/searchers/esjzone.py +43 -25
  128. novel_downloader/core/searchers/hetushu.py +92 -0
  129. novel_downloader/core/searchers/i25zw.py +93 -0
  130. novel_downloader/core/searchers/ixdzs8.py +107 -0
  131. novel_downloader/core/searchers/jpxs123.py +107 -0
  132. novel_downloader/core/searchers/piaotia.py +100 -0
  133. novel_downloader/core/searchers/qbtr.py +106 -0
  134. novel_downloader/core/searchers/qianbi.py +74 -40
  135. novel_downloader/core/searchers/quanben5.py +144 -0
  136. novel_downloader/core/searchers/registry.py +24 -8
  137. novel_downloader/core/searchers/shuhaige.py +124 -0
  138. novel_downloader/core/searchers/tongrenquan.py +110 -0
  139. novel_downloader/core/searchers/ttkan.py +92 -0
  140. novel_downloader/core/searchers/xiaoshuowu.py +122 -0
  141. novel_downloader/core/searchers/xiguashuwu.py +95 -0
  142. novel_downloader/core/searchers/xs63b.py +104 -0
  143. novel_downloader/locales/en.json +31 -82
  144. novel_downloader/locales/zh.json +32 -83
  145. novel_downloader/models/__init__.py +21 -22
  146. novel_downloader/models/book.py +44 -0
  147. novel_downloader/models/config.py +4 -37
  148. novel_downloader/models/login.py +1 -1
  149. novel_downloader/models/search.py +5 -0
  150. novel_downloader/resources/config/settings.toml +8 -70
  151. novel_downloader/resources/json/xiguashuwu.json +718 -0
  152. novel_downloader/utils/__init__.py +13 -22
  153. novel_downloader/utils/chapter_storage.py +3 -2
  154. novel_downloader/utils/constants.py +4 -29
  155. novel_downloader/utils/cookies.py +6 -18
  156. novel_downloader/utils/crypto_utils/__init__.py +13 -0
  157. novel_downloader/utils/crypto_utils/aes_util.py +90 -0
  158. novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
  159. novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
  160. novel_downloader/utils/{crypto_utils.py → crypto_utils/rc4.py} +3 -10
  161. novel_downloader/utils/epub/__init__.py +1 -1
  162. novel_downloader/utils/epub/constants.py +57 -16
  163. novel_downloader/utils/epub/documents.py +88 -194
  164. novel_downloader/utils/epub/models.py +0 -14
  165. novel_downloader/utils/epub/utils.py +63 -96
  166. novel_downloader/utils/file_utils/__init__.py +2 -23
  167. novel_downloader/utils/file_utils/io.py +3 -113
  168. novel_downloader/utils/file_utils/sanitize.py +0 -4
  169. novel_downloader/utils/fontocr.py +207 -0
  170. novel_downloader/utils/logger.py +8 -16
  171. novel_downloader/utils/network.py +2 -2
  172. novel_downloader/utils/state.py +4 -90
  173. novel_downloader/utils/text_utils/__init__.py +1 -7
  174. novel_downloader/utils/text_utils/diff_display.py +5 -7
  175. novel_downloader/utils/time_utils/__init__.py +5 -11
  176. novel_downloader/utils/time_utils/datetime_utils.py +20 -29
  177. novel_downloader/utils/time_utils/sleep_utils.py +4 -8
  178. novel_downloader/web/__init__.py +13 -0
  179. novel_downloader/web/components/__init__.py +11 -0
  180. novel_downloader/web/components/navigation.py +35 -0
  181. novel_downloader/web/main.py +66 -0
  182. novel_downloader/web/pages/__init__.py +17 -0
  183. novel_downloader/web/pages/download.py +78 -0
  184. novel_downloader/web/pages/progress.py +147 -0
  185. novel_downloader/web/pages/search.py +329 -0
  186. novel_downloader/web/services/__init__.py +17 -0
  187. novel_downloader/web/services/client_dialog.py +164 -0
  188. novel_downloader/web/services/cred_broker.py +113 -0
  189. novel_downloader/web/services/cred_models.py +35 -0
  190. novel_downloader/web/services/task_manager.py +264 -0
  191. novel_downloader-2.0.0.dist-info/METADATA +171 -0
  192. novel_downloader-2.0.0.dist-info/RECORD +210 -0
  193. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.0.dist-info}/entry_points.txt +1 -1
  194. novel_downloader/core/downloaders/biquge.py +0 -29
  195. novel_downloader/core/downloaders/esjzone.py +0 -29
  196. novel_downloader/core/downloaders/linovelib.py +0 -29
  197. novel_downloader/core/downloaders/sfacg.py +0 -29
  198. novel_downloader/core/downloaders/yamibo.py +0 -29
  199. novel_downloader/core/exporters/biquge.py +0 -22
  200. novel_downloader/core/exporters/esjzone.py +0 -22
  201. novel_downloader/core/exporters/qianbi.py +0 -22
  202. novel_downloader/core/exporters/sfacg.py +0 -22
  203. novel_downloader/core/exporters/yamibo.py +0 -22
  204. novel_downloader/core/fetchers/base/__init__.py +0 -14
  205. novel_downloader/core/fetchers/base/browser.py +0 -422
  206. novel_downloader/core/fetchers/biquge/__init__.py +0 -14
  207. novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
  208. novel_downloader/core/fetchers/esjzone/browser.py +0 -209
  209. novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
  210. novel_downloader/core/fetchers/linovelib/browser.py +0 -198
  211. novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
  212. novel_downloader/core/fetchers/qidian/__init__.py +0 -14
  213. novel_downloader/core/fetchers/qidian/browser.py +0 -326
  214. novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
  215. novel_downloader/core/fetchers/sfacg/browser.py +0 -194
  216. novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
  217. novel_downloader/core/fetchers/yamibo/browser.py +0 -234
  218. novel_downloader/core/parsers/biquge.py +0 -139
  219. novel_downloader/models/chapter.py +0 -25
  220. novel_downloader/models/types.py +0 -13
  221. novel_downloader/tui/__init__.py +0 -7
  222. novel_downloader/tui/app.py +0 -32
  223. novel_downloader/tui/main.py +0 -17
  224. novel_downloader/tui/screens/__init__.py +0 -14
  225. novel_downloader/tui/screens/home.py +0 -198
  226. novel_downloader/tui/screens/login.py +0 -74
  227. novel_downloader/tui/styles/home_layout.tcss +0 -79
  228. novel_downloader/tui/widgets/richlog_handler.py +0 -24
  229. novel_downloader/utils/cache.py +0 -24
  230. novel_downloader/utils/fontocr/__init__.py +0 -22
  231. novel_downloader/utils/fontocr/hash_store.py +0 -280
  232. novel_downloader/utils/fontocr/hash_utils.py +0 -103
  233. novel_downloader/utils/fontocr/model_loader.py +0 -69
  234. novel_downloader/utils/fontocr/ocr_v1.py +0 -315
  235. novel_downloader/utils/fontocr/ocr_v2.py +0 -764
  236. novel_downloader/utils/fontocr/ocr_v3.py +0 -744
  237. novel_downloader-1.5.0.dist-info/METADATA +0 -196
  238. novel_downloader-1.5.0.dist-info/RECORD +0 -164
  239. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.0.dist-info}/WHEEL +0 -0
  240. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.0.dist-info}/licenses/LICENSE +0 -0
  241. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.services.cred_broker
4
+ -----------------------------------------
5
+
6
+ In-memory credential request broker
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import time
13
+
14
+ from novel_downloader.models import LoginField
15
+
16
+ from .cred_models import CredRequest
17
+
18
+ # wait time for credentials before timing out (seconds)
19
+ REQUEST_TIMEOUT: int = 120
20
+ # Per-claim lease time (seconds)
21
+ CLAIM_TTL: int = 15
22
+
23
+ # Global request store
24
+ _CRED_LOCK = asyncio.Lock()
25
+ _CRED_REQS: dict[str, CredRequest] = {} # req_id -> CredRequest
26
+
27
+
28
+ async def create_cred_request(
29
+ *,
30
+ task_id: str,
31
+ title: str,
32
+ fields: list[LoginField],
33
+ prefill: dict[str, str] | None = None,
34
+ ) -> CredRequest:
35
+ """
36
+ Create and register a new credential request for a task.
37
+ """
38
+ async with _CRED_LOCK:
39
+ req = CredRequest(
40
+ task_id=task_id,
41
+ title=title,
42
+ fields=list(fields),
43
+ prefill=prefill or {},
44
+ )
45
+ _CRED_REQS[req.req_id] = req
46
+ return req
47
+
48
+
49
+ async def claim_next_request(client_id: str) -> CredRequest | None:
50
+ """
51
+ Claim the next pending unclaimed request; also releases expired claims.
52
+ """
53
+ now = time.monotonic()
54
+ async with _CRED_LOCK:
55
+ # release stale claims
56
+ for r in _CRED_REQS.values():
57
+ if (
58
+ (not r.done)
59
+ and r.claimed_by
60
+ and r.claimed_at
61
+ and (now - r.claimed_at) > CLAIM_TTL
62
+ ):
63
+ r.claimed_by = None
64
+ r.claimed_at = None
65
+ # claim one
66
+ for r in _CRED_REQS.values():
67
+ if not r.done and r.claimed_by is None:
68
+ r.claimed_by = client_id
69
+ r.claimed_at = now
70
+ return r
71
+ return None
72
+
73
+
74
+ async def refresh_claim(req_id: str, client_id: str) -> None:
75
+ """
76
+ Extend the claim lease for a request if it is still owned by the client.
77
+ """
78
+ now = time.monotonic()
79
+ async with _CRED_LOCK:
80
+ r = _CRED_REQS.get(req_id)
81
+ if r and (not r.done) and r.claimed_by == client_id:
82
+ r.claimed_at = now
83
+
84
+
85
+ async def complete_request(req_id: str, result: dict[str, str] | None) -> None:
86
+ """
87
+ Resolve a request with credentials (or None for cancel/timeout) and wake waiters.
88
+ """
89
+ async with _CRED_LOCK:
90
+ r = _CRED_REQS.get(req_id)
91
+ if not r or r.done:
92
+ return
93
+ r.result = result
94
+ r.done = True
95
+ r.event.set()
96
+
97
+
98
+ async def get_req_state(req_id: str) -> tuple[bool, bool]:
99
+ """
100
+ Return (exists, done) for a request id.
101
+ """
102
+ async with _CRED_LOCK:
103
+ r = _CRED_REQS.get(req_id)
104
+ if not r:
105
+ return False, False
106
+ return True, r.done
107
+
108
+
109
+ def cleanup_request(req_id: str) -> None:
110
+ """
111
+ Remove a request from the broker (call after the task consumes the result).
112
+ """
113
+ _CRED_REQS.pop(req_id, None)
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.services.cred_models
4
+ -----------------------------------------
5
+
6
+ Lightweight data models for the credential broker
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass, field
13
+ from uuid import uuid4
14
+
15
+ from novel_downloader.models import LoginField
16
+
17
+
18
+ @dataclass
19
+ class CredRequest:
20
+ task_id: str
21
+ title: str
22
+ fields: list[LoginField]
23
+ prefill: dict[str, str] = field(default_factory=dict)
24
+
25
+ # runtime fields
26
+ req_id: str = field(default_factory=lambda: uuid4().hex)
27
+ event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
28
+ result: dict[str, str] | None = None
29
+
30
+ # claim info (times use time.monotonic() seconds)
31
+ claimed_by: str | None = None
32
+ claimed_at: float | None = None
33
+
34
+ # lifecycle
35
+ done: bool = False
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.services.task_manager
4
+ ------------------------------------------
5
+
6
+ Single-worker FIFO task manager for download jobs
7
+ """
8
+
9
+ import asyncio
10
+ import contextlib
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+ from uuid import uuid4
15
+
16
+ from novel_downloader.config import ConfigAdapter, load_config
17
+ from novel_downloader.core import (
18
+ get_downloader,
19
+ get_exporter,
20
+ get_fetcher,
21
+ get_parser,
22
+ )
23
+ from novel_downloader.models import (
24
+ BookConfig,
25
+ LoginField,
26
+ )
27
+ from novel_downloader.utils.cookies import parse_cookies
28
+
29
+ from .cred_broker import (
30
+ REQUEST_TIMEOUT,
31
+ cleanup_request,
32
+ complete_request,
33
+ create_cred_request,
34
+ )
35
+
36
+ Status = Literal["queued", "running", "completed", "cancelled", "failed"]
37
+
38
+
39
+ @dataclass
40
+ class DownloadTask:
41
+ title: str
42
+ site: str
43
+ book_id: str
44
+
45
+ # runtime state
46
+ task_id: str = field(default_factory=lambda: uuid4().hex)
47
+ status: Status = "queued"
48
+ chapters_total: int = 0
49
+ chapters_done: int = 0
50
+ error: str | None = None
51
+ exported_paths: dict[str, Path] | None = None
52
+
53
+ _cancel_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
54
+
55
+ def progress(self) -> float:
56
+ if self.chapters_total <= 0:
57
+ return 0.0
58
+ return self.chapters_done / self.chapters_total
59
+
60
+ def cancel(self) -> None:
61
+ self._cancel_event.set()
62
+ self.status = "cancelled"
63
+
64
+ def is_cancelled(self) -> bool:
65
+ return self._cancel_event.is_set()
66
+
67
+
68
+ class TaskManager:
69
+ """
70
+ A cooperative, single-worker queue that executes download tasks in order.
71
+ """
72
+
73
+ def __init__(self) -> None:
74
+ self.pending: list[DownloadTask] = []
75
+ self.running: DownloadTask | None = None
76
+ self.completed: list[DownloadTask] = []
77
+ self._new_item = asyncio.Event()
78
+ self._worker_task: asyncio.Task[None] | None = None
79
+ self._lock = asyncio.Lock()
80
+
81
+ self._settings = load_config()
82
+
83
+ # ---------- public API ----------
84
+ async def add_task(self, *, title: str, site: str, book_id: str) -> DownloadTask:
85
+ """
86
+ Enqueue a new task and ensure the worker is running; return the created task.
87
+ """
88
+ t = DownloadTask(title=title, site=site, book_id=book_id)
89
+ async with self._lock:
90
+ self.pending.append(t)
91
+ self._new_item.set()
92
+ if not self._worker_task or self._worker_task.done():
93
+ self._worker_task = asyncio.create_task(self._worker())
94
+ return t
95
+
96
+ async def cancel_task(self, task_id: str) -> bool:
97
+ """Cancel a task by id (pending or currently running)"""
98
+ async with self._lock:
99
+ # cancel pending
100
+ for i, t in enumerate(self.pending):
101
+ if t.task_id == task_id:
102
+ t.cancel()
103
+ self.completed.insert(0, t)
104
+ del self.pending[i]
105
+ return True
106
+ # cancel running
107
+ if self.running and self.running.task_id == task_id:
108
+ self.running.cancel()
109
+ return True
110
+ return False
111
+
112
+ def snapshot(self) -> dict[str, Any]:
113
+ """
114
+ Return a shallow copy of the current queue state (running, pending, completed).
115
+ """
116
+ return {
117
+ "running": self.running,
118
+ "pending": list(self.pending),
119
+ "completed": list(self.completed),
120
+ }
121
+
122
+ # ---------- internals ----------
123
+ async def _worker(self) -> None:
124
+ while True:
125
+ await self._new_item.wait()
126
+ self._new_item.clear()
127
+ while True:
128
+ async with self._lock:
129
+ if self.running is not None:
130
+ break
131
+ if not self.pending:
132
+ break
133
+ task = self.pending.pop(0)
134
+ self.running = task
135
+
136
+ await self._run_task(task)
137
+
138
+ async with self._lock:
139
+ self.completed.insert(0, task)
140
+ self.running = None
141
+
142
+ async def _run_task(self, task: DownloadTask) -> None:
143
+ task.status = "running"
144
+ try:
145
+ adapter = ConfigAdapter(config=self._settings, site=task.site)
146
+ downloader_cfg = adapter.get_downloader_config()
147
+ fetcher_cfg = adapter.get_fetcher_config()
148
+ parser_cfg = adapter.get_parser_config()
149
+ exporter_cfg = adapter.get_exporter_config()
150
+ login_cfg = adapter.get_login_config()
151
+
152
+ parser = get_parser(task.site, parser_cfg)
153
+ exporter = get_exporter(task.site, exporter_cfg)
154
+
155
+ async with get_fetcher(task.site, fetcher_cfg) as fetcher:
156
+ # login if required
157
+ if downloader_cfg.login_required and not await fetcher.load_state():
158
+ login_data = await self._prompt_login_fields(
159
+ task, fetcher.login_fields, login_cfg
160
+ )
161
+ if not await fetcher.login(**login_data):
162
+ task.status = "failed"
163
+ task.error = "登录失败或已取消"
164
+ return
165
+ await fetcher.save_state()
166
+
167
+ downloader = get_downloader(
168
+ fetcher=fetcher,
169
+ parser=parser,
170
+ site=task.site,
171
+ config=downloader_cfg,
172
+ )
173
+
174
+ async def _progress_hook(done: int, total: int) -> None:
175
+ if total and (
176
+ task.chapters_total <= 0 or total > task.chapters_total
177
+ ):
178
+ task.chapters_total = total
179
+ task.chapters_done = done
180
+ # allow cooperative cancel from UI
181
+ if task._cancel_event.is_set():
182
+ raise asyncio.CancelledError()
183
+
184
+ book_cfg: BookConfig = {"book_id": task.book_id}
185
+ try:
186
+ await downloader.download(
187
+ book_cfg,
188
+ progress_hook=_progress_hook,
189
+ cancel_event=task._cancel_event,
190
+ )
191
+ except asyncio.CancelledError:
192
+ task.status = "cancelled"
193
+ return
194
+
195
+ if task.is_cancelled():
196
+ task.status = "cancelled"
197
+ return
198
+
199
+ task.exported_paths = await asyncio.to_thread(
200
+ exporter.export, task.book_id
201
+ )
202
+
203
+ if downloader_cfg.login_required and fetcher.is_logged_in:
204
+ await fetcher.save_state()
205
+
206
+ task.status = "completed"
207
+
208
+ except Exception as e:
209
+ task.status = "failed"
210
+ task.error = str(e)
211
+
212
+ async def _prompt_login_fields(
213
+ self,
214
+ task: DownloadTask,
215
+ fields: list[LoginField],
216
+ login_config: dict[str, str] | None = None,
217
+ ) -> dict[str, Any]:
218
+ """
219
+ Prompt UI login; supports text/password/cookie fields.
220
+ """
221
+
222
+ prefill = (login_config or {}).copy()
223
+ req = await create_cred_request(
224
+ task_id=task.task_id,
225
+ title=task.title,
226
+ fields=fields,
227
+ prefill=prefill,
228
+ )
229
+
230
+ # wait for UI to submit or cancel
231
+ try:
232
+ await asyncio.wait_for(req.event.wait(), timeout=REQUEST_TIMEOUT)
233
+ except TimeoutError:
234
+ await complete_request(req.req_id, None)
235
+ cleanup_request(req.req_id)
236
+ return prefill
237
+
238
+ if task.is_cancelled():
239
+ await complete_request(req.req_id, None)
240
+ cleanup_request(req.req_id)
241
+ return prefill
242
+
243
+ # merge values: prefill -> UI (UI wins)
244
+ ui_vals: dict[str, str] = req.result or {}
245
+ cleanup_request(req.req_id)
246
+
247
+ merged: dict[str, Any] = {
248
+ k: v.strip() for k, v in prefill.items() if isinstance(v, str)
249
+ }
250
+ merged.update({k: v.strip() for k, v in ui_vals.items() if isinstance(v, str)})
251
+
252
+ # parse cookie fields into dicts
253
+ for f in fields:
254
+ if f.type == "cookie":
255
+ raw = merged.get(f.name, "")
256
+ if isinstance(raw, str) and raw:
257
+ with contextlib.suppress(Exception):
258
+ # keep raw string if parsing fails
259
+ merged[f.name] = parse_cookies(raw)
260
+
261
+ return merged
262
+
263
+
264
+ manager = TaskManager()
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: novel-downloader
3
+ Version: 2.0.0
4
+ Summary: A command-line tool for downloading Chinese web novels from Qidian and similar platforms.
5
+ Author-email: Saudade Z <saudadez217@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Saudade Z
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/saudadez21/novel-downloader
29
+ Project-URL: Source, https://github.com/saudadez21/novel-downloader
30
+ Keywords: novel,web novel,qidian,biquge,ebook
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Environment :: Console
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Natural Language :: Chinese (Simplified)
35
+ Classifier: Topic :: Utilities
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Requires-Python: >=3.11
41
+ Description-Content-Type: text/markdown
42
+ License-File: LICENSE
43
+ Requires-Dist: rich
44
+ Requires-Dist: nicegui
45
+ Requires-Dist: requests
46
+ Requires-Dist: aiohttp
47
+ Requires-Dist: lxml
48
+ Requires-Dist: platformdirs
49
+ Provides-Extra: font-recovery
50
+ Requires-Dist: numpy; extra == "font-recovery"
51
+ Requires-Dist: fonttools; extra == "font-recovery"
52
+ Requires-Dist: pillow; extra == "font-recovery"
53
+ Provides-Extra: dev
54
+ Requires-Dist: black; extra == "dev"
55
+ Requires-Dist: mypy; extra == "dev"
56
+ Requires-Dist: ruff; extra == "dev"
57
+ Requires-Dist: pytest; extra == "dev"
58
+ Requires-Dist: pytest-cov; extra == "dev"
59
+ Requires-Dist: pytest-mock; extra == "dev"
60
+ Requires-Dist: types-requests; extra == "dev"
61
+ Requires-Dist: types-lxml; extra == "dev"
62
+ Requires-Dist: types-PyYAML; extra == "dev"
63
+ Requires-Dist: pre-commit; extra == "dev"
64
+ Requires-Dist: commitizen; extra == "dev"
65
+ Dynamic: license-file
66
+
67
+ # novel-downloader
68
+
69
+ 基于 [aiohttp](https://github.com/aio-libs/aiohttp) 的异步小说下载工具 / 库。支持断点续传、广告过滤与 TXT/EPUB 导出, 提供 CLI 与 Web 图形界面。
70
+
71
+ > 运行要求: **Python 3.11+** (开发环境: Python 3.12)
72
+
73
+ ## 功能特性
74
+
75
+ * **可恢复下载**: 运行时自动检测本地已完成的部分, 跳过已下载内容
76
+ * **多格式导出**: 合并所有章节为
77
+ * `TXT`
78
+ * `EPUB` (可选打包章节插图)
79
+ * **广告/活动过滤**:
80
+ * [x] 章节标题过滤
81
+ * [x] 章节正文过滤
82
+ * **可选字体混淆还原**: `decode_font`
83
+ * **双形态使用**: 命令行 (CLI) 与 Web 图形界面 (GUI)
84
+
85
+ ---
86
+
87
+ ## 安装
88
+
89
+ 使用 `pip` 安装稳定版:
90
+
91
+ ```bash
92
+ pip install novel-downloader
93
+ ```
94
+
95
+ 启用字体解密功能 (`decode_font`):
96
+
97
+ ```bash
98
+ pip install "novel-downloader[font-recovery]"
99
+ ```
100
+
101
+ > 参见: [安装](https://github.com/saudadez21/novel-downloader/blob/main/docs/1-installation.md)
102
+
103
+ ---
104
+
105
+ ## 快速开始
106
+
107
+ ### 1. 初始化配置
108
+
109
+ ```bash
110
+ # 生成默认配置 ./settings.toml
111
+ novel-cli config init
112
+ ```
113
+
114
+ 编辑生成的 `./settings.toml`, 可修改 `request_interval`、`book_ids` 等配置 (参考 [settings.toml 配置说明](https://github.com/saudadez21/novel-downloader/blob/main/docs/3-settings-schema.md))
115
+
116
+ ### 2. 命令行 (CLI)
117
+
118
+ ```bash
119
+ # 执行下载任务 (示例: 书籍 ID 为 123456, 默认站点为起点)
120
+ novel-cli download 123456
121
+ ```
122
+
123
+ * 支持站点见: [支持站点列表](https://github.com/saudadez21/novel-downloader/blob/main/docs/4-supported-sites.md)
124
+ * 更多示例见: [CLI 使用示例](https://github.com/saudadez21/novel-downloader/blob/main/docs/5-cli-usage-examples.md)
125
+
126
+ ### 3. 图形界面 (GUI / Web)
127
+
128
+ ```bash
129
+ # 启动 Web 界面 (基于当前 settings.toml)
130
+ novel-web
131
+
132
+ # 如需提供局域网/外网访问 (请自行留意安全与网络环境)
133
+ # novel-web --listen public
134
+ ```
135
+
136
+ ---
137
+
138
+ ## 从源码安装 (开发版)
139
+
140
+ 体验最新开发功能:
141
+
142
+ ```bash
143
+ git clone https://github.com/saudadez21/novel-downloader.git
144
+ cd novel-downloader
145
+ pip install .
146
+ # 或安装带可选功能:
147
+ # pip install .[font-recovery]
148
+ ```
149
+
150
+ ---
151
+
152
+ ## 文档导航
153
+
154
+ * [安装](https://github.com/saudadez21/novel-downloader/blob/main/docs/1-installation.md)
155
+ * [配置](https://github.com/saudadez21/novel-downloader/blob/main/docs/2-configuration.md)
156
+ * [settings.toml 配置说明](https://github.com/saudadez21/novel-downloader/blob/main/docs/3-settings-schema.md)
157
+ * [支持站点列表](https://github.com/saudadez21/novel-downloader/blob/main/docs/4-supported-sites.md)
158
+ * [CLI 使用示例](https://github.com/saudadez21/novel-downloader/blob/main/docs/5-cli-usage-examples.md)
159
+ * [复制 Cookies](https://github.com/saudadez21/novel-downloader/blob/main/docs/copy-cookies.md)
160
+ * [文件保存](https://github.com/saudadez21/novel-downloader/blob/main/docs/file-saving.md)
161
+ * [模块与接口文档](https://github.com/saudadez21/novel-downloader/blob/main/docs/api/README.md)
162
+ * [TODO](https://github.com/saudadez21/novel-downloader/blob/main/docs/todo.md)
163
+ * [开发](https://github.com/saudadez21/novel-downloader/blob/main/docs/develop.md)
164
+
165
+ ---
166
+
167
+ ## 项目说明
168
+
169
+ * 本项目仅供学习和研究使用, **不得**用于任何商业或违法用途; 请遵守目标网站的 `robots.txt` 及相关法律法规
170
+ * 由于网站结构可能变化或其他问题, 可能导致无法正常工作, 请按需自行调整代码或寻找其他解决方案
171
+ * 使用本项目造成的任何法律责任由使用者自行承担, 项目作者不承担相关责任