novel-downloader 1.4.5__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 (276) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/__init__.py +2 -4
  3. novel_downloader/cli/clean.py +21 -88
  4. novel_downloader/cli/config.py +27 -104
  5. novel_downloader/cli/download.py +78 -66
  6. novel_downloader/cli/export.py +20 -21
  7. novel_downloader/cli/main.py +3 -1
  8. novel_downloader/cli/search.py +120 -0
  9. novel_downloader/cli/ui.py +156 -0
  10. novel_downloader/config/__init__.py +10 -14
  11. novel_downloader/config/adapter.py +195 -99
  12. novel_downloader/config/{loader.py → file_io.py} +53 -27
  13. novel_downloader/core/__init__.py +14 -13
  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/archived/qidian/searcher.py +79 -0
  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 +8 -30
  21. novel_downloader/core/downloaders/base.py +182 -30
  22. novel_downloader/core/downloaders/common.py +217 -384
  23. novel_downloader/core/downloaders/qianbi.py +332 -4
  24. novel_downloader/core/downloaders/qidian.py +250 -290
  25. novel_downloader/core/downloaders/registry.py +69 -0
  26. novel_downloader/core/downloaders/signals.py +46 -0
  27. novel_downloader/core/exporters/__init__.py +8 -26
  28. novel_downloader/core/exporters/base.py +107 -31
  29. novel_downloader/core/exporters/common/__init__.py +3 -4
  30. novel_downloader/core/exporters/common/epub.py +92 -171
  31. novel_downloader/core/exporters/common/main_exporter.py +14 -67
  32. novel_downloader/core/exporters/common/txt.py +90 -86
  33. novel_downloader/core/exporters/epub_util.py +184 -1327
  34. novel_downloader/core/exporters/linovelib/__init__.py +3 -2
  35. novel_downloader/core/exporters/linovelib/epub.py +165 -222
  36. novel_downloader/core/exporters/linovelib/main_exporter.py +10 -71
  37. novel_downloader/core/exporters/linovelib/txt.py +76 -66
  38. novel_downloader/core/exporters/qidian.py +15 -11
  39. novel_downloader/core/exporters/registry.py +55 -0
  40. novel_downloader/core/exporters/txt_util.py +67 -0
  41. novel_downloader/core/fetchers/__init__.py +57 -56
  42. novel_downloader/core/fetchers/aaatxt.py +83 -0
  43. novel_downloader/core/fetchers/{biquge/session.py → b520.py} +10 -10
  44. novel_downloader/core/fetchers/{base/session.py → base.py} +63 -47
  45. novel_downloader/core/fetchers/biquyuedu.py +83 -0
  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} +23 -11
  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} +22 -26
  52. novel_downloader/core/fetchers/ixdzs8.py +113 -0
  53. novel_downloader/core/fetchers/jpxs123.py +101 -0
  54. novel_downloader/core/fetchers/{biquge/browser.py → lewenn.py} +15 -15
  55. novel_downloader/core/fetchers/{linovelib/session.py → linovelib.py} +16 -12
  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} +9 -9
  59. novel_downloader/core/fetchers/{qidian/session.py → qidian.py} +55 -40
  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 +60 -0
  63. novel_downloader/core/fetchers/{sfacg/session.py → sfacg.py} +11 -9
  64. novel_downloader/core/fetchers/shencou.py +106 -0
  65. novel_downloader/core/fetchers/{common/browser.py → shuhaige.py} +24 -19
  66. novel_downloader/core/fetchers/tongrenquan.py +84 -0
  67. novel_downloader/core/fetchers/ttkan.py +95 -0
  68. novel_downloader/core/fetchers/{common/session.py → wanbengo.py} +21 -17
  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} +23 -11
  74. novel_downloader/core/fetchers/yibige.py +114 -0
  75. novel_downloader/core/interfaces/__init__.py +8 -14
  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 +26 -0
  81. novel_downloader/core/parsers/__init__.py +58 -22
  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/main_parser.py → esjzone.py} +67 -67
  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/main_parser.py → linovelib.py} +54 -65
  96. novel_downloader/core/parsers/piaotia.py +189 -0
  97. novel_downloader/core/parsers/qbtr.py +136 -0
  98. novel_downloader/core/parsers/{qianbi/main_parser.py → qianbi.py} +54 -51
  99. novel_downloader/core/parsers/qidian/__init__.py +2 -2
  100. novel_downloader/core/parsers/qidian/book_info_parser.py +58 -59
  101. novel_downloader/core/parsers/qidian/chapter_encrypted.py +290 -346
  102. novel_downloader/core/parsers/qidian/chapter_normal.py +25 -56
  103. novel_downloader/core/parsers/qidian/main_parser.py +19 -57
  104. novel_downloader/core/parsers/qidian/utils/__init__.py +12 -11
  105. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +6 -7
  106. novel_downloader/core/parsers/qidian/utils/fontmap_recover.py +143 -0
  107. novel_downloader/core/parsers/qidian/utils/helpers.py +0 -4
  108. novel_downloader/core/parsers/qidian/utils/node_decryptor.py +2 -2
  109. novel_downloader/core/parsers/quanben5.py +103 -0
  110. novel_downloader/core/parsers/registry.py +57 -0
  111. novel_downloader/core/parsers/{sfacg/main_parser.py → sfacg.py} +46 -48
  112. novel_downloader/core/parsers/shencou.py +215 -0
  113. novel_downloader/core/parsers/shuhaige.py +111 -0
  114. novel_downloader/core/parsers/tongrenquan.py +116 -0
  115. novel_downloader/core/parsers/ttkan.py +132 -0
  116. novel_downloader/core/parsers/wanbengo.py +191 -0
  117. novel_downloader/core/parsers/xiaoshuowu.py +173 -0
  118. novel_downloader/core/parsers/xiguashuwu.py +435 -0
  119. novel_downloader/core/parsers/xs63b.py +161 -0
  120. novel_downloader/core/parsers/xshbook.py +134 -0
  121. novel_downloader/core/parsers/yamibo.py +155 -0
  122. novel_downloader/core/parsers/yibige.py +166 -0
  123. novel_downloader/core/searchers/__init__.py +51 -0
  124. novel_downloader/core/searchers/aaatxt.py +107 -0
  125. novel_downloader/core/searchers/b520.py +84 -0
  126. novel_downloader/core/searchers/base.py +168 -0
  127. novel_downloader/core/searchers/dxmwx.py +105 -0
  128. novel_downloader/core/searchers/eightnovel.py +84 -0
  129. novel_downloader/core/searchers/esjzone.py +102 -0
  130. novel_downloader/core/searchers/hetushu.py +92 -0
  131. novel_downloader/core/searchers/i25zw.py +93 -0
  132. novel_downloader/core/searchers/ixdzs8.py +107 -0
  133. novel_downloader/core/searchers/jpxs123.py +107 -0
  134. novel_downloader/core/searchers/piaotia.py +100 -0
  135. novel_downloader/core/searchers/qbtr.py +106 -0
  136. novel_downloader/core/searchers/qianbi.py +165 -0
  137. novel_downloader/core/searchers/quanben5.py +144 -0
  138. novel_downloader/core/searchers/registry.py +79 -0
  139. novel_downloader/core/searchers/shuhaige.py +124 -0
  140. novel_downloader/core/searchers/tongrenquan.py +110 -0
  141. novel_downloader/core/searchers/ttkan.py +92 -0
  142. novel_downloader/core/searchers/xiaoshuowu.py +122 -0
  143. novel_downloader/core/searchers/xiguashuwu.py +95 -0
  144. novel_downloader/core/searchers/xs63b.py +104 -0
  145. novel_downloader/locales/en.json +36 -79
  146. novel_downloader/locales/zh.json +37 -80
  147. novel_downloader/models/__init__.py +23 -50
  148. novel_downloader/models/book.py +44 -0
  149. novel_downloader/models/config.py +16 -43
  150. novel_downloader/models/login.py +1 -1
  151. novel_downloader/models/search.py +21 -0
  152. novel_downloader/resources/config/settings.toml +39 -74
  153. novel_downloader/resources/css_styles/intro.css +83 -0
  154. novel_downloader/resources/css_styles/main.css +30 -89
  155. novel_downloader/resources/json/xiguashuwu.json +718 -0
  156. novel_downloader/utils/__init__.py +43 -0
  157. novel_downloader/utils/chapter_storage.py +247 -226
  158. novel_downloader/utils/constants.py +5 -50
  159. novel_downloader/utils/cookies.py +6 -18
  160. novel_downloader/utils/crypto_utils/__init__.py +13 -0
  161. novel_downloader/utils/crypto_utils/aes_util.py +90 -0
  162. novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
  163. novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
  164. novel_downloader/utils/{crypto_utils.py → crypto_utils/rc4.py} +3 -10
  165. novel_downloader/utils/epub/__init__.py +34 -0
  166. novel_downloader/utils/epub/builder.py +377 -0
  167. novel_downloader/utils/epub/constants.py +118 -0
  168. novel_downloader/utils/epub/documents.py +297 -0
  169. novel_downloader/utils/epub/models.py +120 -0
  170. novel_downloader/utils/epub/utils.py +179 -0
  171. novel_downloader/utils/file_utils/__init__.py +5 -30
  172. novel_downloader/utils/file_utils/io.py +9 -150
  173. novel_downloader/utils/file_utils/normalize.py +2 -2
  174. novel_downloader/utils/file_utils/sanitize.py +2 -7
  175. novel_downloader/utils/fontocr.py +207 -0
  176. novel_downloader/utils/i18n.py +2 -0
  177. novel_downloader/utils/logger.py +10 -16
  178. novel_downloader/utils/network.py +111 -252
  179. novel_downloader/utils/state.py +5 -90
  180. novel_downloader/utils/text_utils/__init__.py +16 -21
  181. novel_downloader/utils/text_utils/diff_display.py +6 -9
  182. novel_downloader/utils/text_utils/numeric_conversion.py +253 -0
  183. novel_downloader/utils/text_utils/text_cleaner.py +179 -0
  184. novel_downloader/utils/text_utils/truncate_utils.py +62 -0
  185. novel_downloader/utils/time_utils/__init__.py +6 -12
  186. novel_downloader/utils/time_utils/datetime_utils.py +23 -33
  187. novel_downloader/utils/time_utils/sleep_utils.py +5 -10
  188. novel_downloader/web/__init__.py +13 -0
  189. novel_downloader/web/components/__init__.py +11 -0
  190. novel_downloader/web/components/navigation.py +35 -0
  191. novel_downloader/web/main.py +66 -0
  192. novel_downloader/web/pages/__init__.py +17 -0
  193. novel_downloader/web/pages/download.py +78 -0
  194. novel_downloader/web/pages/progress.py +147 -0
  195. novel_downloader/web/pages/search.py +329 -0
  196. novel_downloader/web/services/__init__.py +17 -0
  197. novel_downloader/web/services/client_dialog.py +164 -0
  198. novel_downloader/web/services/cred_broker.py +113 -0
  199. novel_downloader/web/services/cred_models.py +35 -0
  200. novel_downloader/web/services/task_manager.py +264 -0
  201. novel_downloader-2.0.0.dist-info/METADATA +171 -0
  202. novel_downloader-2.0.0.dist-info/RECORD +210 -0
  203. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/entry_points.txt +1 -1
  204. novel_downloader/config/site_rules.py +0 -94
  205. novel_downloader/core/downloaders/biquge.py +0 -25
  206. novel_downloader/core/downloaders/esjzone.py +0 -25
  207. novel_downloader/core/downloaders/linovelib.py +0 -25
  208. novel_downloader/core/downloaders/sfacg.py +0 -25
  209. novel_downloader/core/downloaders/yamibo.py +0 -25
  210. novel_downloader/core/exporters/biquge.py +0 -25
  211. novel_downloader/core/exporters/esjzone.py +0 -25
  212. novel_downloader/core/exporters/qianbi.py +0 -25
  213. novel_downloader/core/exporters/sfacg.py +0 -25
  214. novel_downloader/core/exporters/yamibo.py +0 -25
  215. novel_downloader/core/factory/__init__.py +0 -20
  216. novel_downloader/core/factory/downloader.py +0 -73
  217. novel_downloader/core/factory/exporter.py +0 -58
  218. novel_downloader/core/factory/fetcher.py +0 -96
  219. novel_downloader/core/factory/parser.py +0 -86
  220. novel_downloader/core/fetchers/base/__init__.py +0 -14
  221. novel_downloader/core/fetchers/base/browser.py +0 -403
  222. novel_downloader/core/fetchers/biquge/__init__.py +0 -14
  223. novel_downloader/core/fetchers/common/__init__.py +0 -14
  224. novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
  225. novel_downloader/core/fetchers/esjzone/browser.py +0 -204
  226. novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
  227. novel_downloader/core/fetchers/linovelib/browser.py +0 -193
  228. novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
  229. novel_downloader/core/fetchers/qidian/__init__.py +0 -14
  230. novel_downloader/core/fetchers/qidian/browser.py +0 -318
  231. novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
  232. novel_downloader/core/fetchers/sfacg/browser.py +0 -189
  233. novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
  234. novel_downloader/core/fetchers/yamibo/browser.py +0 -229
  235. novel_downloader/core/parsers/biquge/__init__.py +0 -10
  236. novel_downloader/core/parsers/biquge/main_parser.py +0 -134
  237. novel_downloader/core/parsers/common/__init__.py +0 -13
  238. novel_downloader/core/parsers/common/helper.py +0 -323
  239. novel_downloader/core/parsers/common/main_parser.py +0 -106
  240. novel_downloader/core/parsers/esjzone/__init__.py +0 -10
  241. novel_downloader/core/parsers/linovelib/__init__.py +0 -10
  242. novel_downloader/core/parsers/qianbi/__init__.py +0 -10
  243. novel_downloader/core/parsers/sfacg/__init__.py +0 -10
  244. novel_downloader/core/parsers/yamibo/__init__.py +0 -10
  245. novel_downloader/core/parsers/yamibo/main_parser.py +0 -194
  246. novel_downloader/models/browser.py +0 -21
  247. novel_downloader/models/chapter.py +0 -25
  248. novel_downloader/models/site_rules.py +0 -99
  249. novel_downloader/models/tasks.py +0 -33
  250. novel_downloader/models/types.py +0 -15
  251. novel_downloader/resources/css_styles/volume-intro.css +0 -56
  252. novel_downloader/resources/json/replace_word_map.json +0 -4
  253. novel_downloader/resources/text/blacklist.txt +0 -22
  254. novel_downloader/tui/__init__.py +0 -7
  255. novel_downloader/tui/app.py +0 -32
  256. novel_downloader/tui/main.py +0 -17
  257. novel_downloader/tui/screens/__init__.py +0 -14
  258. novel_downloader/tui/screens/home.py +0 -198
  259. novel_downloader/tui/screens/login.py +0 -74
  260. novel_downloader/tui/styles/home_layout.tcss +0 -79
  261. novel_downloader/tui/widgets/richlog_handler.py +0 -24
  262. novel_downloader/utils/cache.py +0 -24
  263. novel_downloader/utils/fontocr/__init__.py +0 -22
  264. novel_downloader/utils/fontocr/model_loader.py +0 -69
  265. novel_downloader/utils/fontocr/ocr_v1.py +0 -303
  266. novel_downloader/utils/fontocr/ocr_v2.py +0 -752
  267. novel_downloader/utils/hash_store.py +0 -279
  268. novel_downloader/utils/hash_utils.py +0 -103
  269. novel_downloader/utils/text_utils/chapter_formatting.py +0 -46
  270. novel_downloader/utils/text_utils/font_mapping.py +0 -28
  271. novel_downloader/utils/text_utils/text_cleaning.py +0 -107
  272. novel_downloader-1.4.5.dist-info/METADATA +0 -196
  273. novel_downloader-1.4.5.dist-info/RECORD +0 -165
  274. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/WHEEL +0 -0
  275. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/licenses/LICENSE +0 -0
  276. {novel_downloader-1.4.5.dist-info → novel_downloader-2.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.services.client_dialog
4
+ -------------------------------------------
5
+
6
+ Register a per-page login dialog and a polling timer to claim/handle credential requests
7
+ """
8
+
9
+ import asyncio
10
+ import contextlib
11
+ from typing import Any
12
+
13
+ from nicegui import ui
14
+
15
+ from novel_downloader.models import LoginField
16
+
17
+ from .cred_broker import (
18
+ claim_next_request,
19
+ complete_request,
20
+ get_req_state,
21
+ refresh_claim,
22
+ )
23
+
24
+
25
+ def setup_dialog() -> None:
26
+ """
27
+ Register the login dialog and a small poller in the current page context.
28
+ """
29
+ client_id = ui.context.client.id
30
+
31
+ # local state per page instance
32
+ curr_req_id: str | None = None
33
+
34
+ with ui.dialog() as dialog, ui.card().classes("min-w-[360px]"):
35
+ title_label = ui.label("登录请求").classes("text-base font-medium")
36
+
37
+ # dynamic form container
38
+ form_col = ui.column().classes("w-full gap-2 mt-2")
39
+
40
+ # name -> widget
41
+ inputs: dict[str, Any] = {}
42
+
43
+ def _build_form(req_fields: list[LoginField], prefill: dict[str, str]) -> None:
44
+ """
45
+ (Re)build inputs inside the dialog's form_col based on LoginField list.
46
+ """
47
+ form_col.clear()
48
+ inputs.clear()
49
+ with form_col:
50
+ for idx, f in enumerate(req_fields):
51
+ with ui.column().classes("w-full gap-1"):
52
+ ui.label(f.label).classes("text-sm font-medium")
53
+ if getattr(f, "description", ""):
54
+ ui.label(f.description).classes("text-xs text-grey-6")
55
+
56
+ initial = prefill.get(f.name, f.default or "")
57
+
58
+ # choose widget by type
59
+ if f.type == "password":
60
+ w = (
61
+ ui.input(
62
+ f.label,
63
+ password=True,
64
+ value=initial,
65
+ placeholder=f.placeholder or "",
66
+ )
67
+ .props("dense")
68
+ .classes("w-full")
69
+ )
70
+ w.label = None
71
+ elif f.type == "cookie":
72
+ # cookie can be long
73
+ w = (
74
+ ui.textarea(
75
+ f.label,
76
+ value=initial,
77
+ placeholder=f.placeholder or "",
78
+ )
79
+ .props("dense")
80
+ .classes("w-full")
81
+ )
82
+ w.label = None
83
+ else:
84
+ # default: text
85
+ w = (
86
+ ui.input(
87
+ f.label,
88
+ value=initial,
89
+ placeholder=f.placeholder or "",
90
+ )
91
+ .props("dense")
92
+ .classes("w-full")
93
+ )
94
+ w.label = None
95
+
96
+ # optional niceties
97
+ if getattr(f, "required", False):
98
+ w.props("required")
99
+ if idx == 0:
100
+ w.props("autofocus")
101
+
102
+ inputs[f.name] = w
103
+
104
+ def on_cancel() -> None:
105
+ nonlocal curr_req_id
106
+ if curr_req_id:
107
+ asyncio.create_task(complete_request(curr_req_id, None))
108
+ curr_req_id = None
109
+ dialog.close()
110
+ ui.notify("已取消登录")
111
+
112
+ def on_submit() -> None:
113
+ nonlocal curr_req_id
114
+ if not curr_req_id:
115
+ return
116
+ # collect values
117
+ values: dict[str, str] = {}
118
+ for name, w in inputs.items():
119
+ values[name] = (w.value or "").strip()
120
+ # send to broker
121
+ asyncio.create_task(complete_request(curr_req_id, values))
122
+ curr_req_id = None
123
+ dialog.close()
124
+ ui.notify("提交成功")
125
+
126
+ with ui.row().classes("justify-end w-full mt-2"):
127
+ ui.button("取消", on_click=on_cancel).props("flat color=grey-7")
128
+ ui.button("提交", on_click=on_submit)
129
+
130
+ dialog.props("persistent")
131
+
132
+ async def check_and_open() -> None:
133
+ nonlocal curr_req_id
134
+ rid = curr_req_id
135
+ if rid:
136
+ exists, done = await get_req_state(rid)
137
+ if (not exists) or done:
138
+ curr_req_id = None
139
+ if dialog.visible:
140
+ dialog.close()
141
+ else:
142
+ await refresh_claim(rid, client_id)
143
+ return
144
+
145
+ req = await claim_next_request(client_id)
146
+ if not req:
147
+ return
148
+ curr_req_id = req.req_id
149
+ title_label.text = f"'{req.title}' 需要登录信息"
150
+ _build_form(req.fields, req.prefill)
151
+ dialog.open()
152
+
153
+ ui.timer(0.5, check_and_open)
154
+
155
+ # Clean up if this page's websocket disconnects
156
+ def _on_disconnect() -> None:
157
+ nonlocal curr_req_id
158
+ if curr_req_id:
159
+ asyncio.create_task(complete_request(curr_req_id, None))
160
+ curr_req_id = None
161
+
162
+ with contextlib.suppress(Exception):
163
+ ui.context.client.on_disconnect(_on_disconnect)
164
+ # Fallback: CLAIM_TTL will release stale claims anyway
@@ -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()