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,147 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.pages.progress
4
+ -----------------------------------
5
+
6
+ Layout for active/history tasks with compact cards and status chips.
7
+ """
8
+
9
+
10
+ from nicegui import ui
11
+
12
+ from novel_downloader.web.components import navbar
13
+ from novel_downloader.web.services import DownloadTask, Status, manager, setup_dialog
14
+
15
+
16
+ def _status_chip(status: Status) -> None:
17
+ label_map = {
18
+ "queued": "已排队",
19
+ "running": "运行中",
20
+ "completed": "完成",
21
+ "cancelled": "已取消",
22
+ "failed": "失败",
23
+ }
24
+ color_map = {
25
+ "queued": "warning",
26
+ "running": "primary",
27
+ "completed": "positive",
28
+ "cancelled": "grey-6",
29
+ "failed": "negative",
30
+ }
31
+ ui.chip(label_map[status]).props(
32
+ f"outline color={color_map[status]} dense"
33
+ ).classes("q-ml-sm")
34
+
35
+
36
+ def _meta_row(label: str, value: str) -> None:
37
+ with ui.row().classes("items-center justify-between text-xs text-grey-7 w-full"):
38
+ ui.label(label)
39
+ ui.label(value)
40
+
41
+
42
+ def _progress_block(t: DownloadTask) -> None:
43
+ # progress or summary depending on state
44
+ if t.status == "running":
45
+ if t.chapters_total <= 0:
46
+ label_text = f"{t.chapters_done}/? · 正在获取总章节..."
47
+ ui.linear_progress().props("indeterminate striped").classes("w-full")
48
+ ui.label(label_text).classes("text-xs text-grey-7")
49
+ else:
50
+ ui.linear_progress(value=t.progress()).props("instant-feedback").classes(
51
+ "w-full"
52
+ )
53
+ ui.label(f"{t.chapters_done}/{t.chapters_total} · running").classes(
54
+ "text-xs text-grey-7"
55
+ )
56
+ else:
57
+ suffix = {"completed": "完成", "cancelled": "已取消", "failed": "失败"}.get(
58
+ t.status, ""
59
+ )
60
+ if t.chapters_total > 0:
61
+ ui.label(f"{t.chapters_done}/{t.chapters_total} · {suffix}").classes(
62
+ "text-xs text-grey-7"
63
+ )
64
+ else:
65
+ ui.label(f"{t.chapters_done}/? · {suffix}").classes("text-xs text-grey-7")
66
+
67
+ if t.status == "completed" and t.exported_paths:
68
+ with ui.row().classes("w-full gap-2 mt-1"):
69
+ for key, p in t.exported_paths.items():
70
+ url = f"/download/{p.name}?v={t.task_id}"
71
+ ui.button(key, on_click=lambda e, url=url: ui.download(url)).props(
72
+ "outline size=sm"
73
+ )
74
+
75
+
76
+ def _task_card(t: DownloadTask, *, active: bool) -> None:
77
+ with ui.card().classes("w-full"):
78
+ # header
79
+ with ui.row().classes("items-center justify-between w-full"):
80
+ with ui.row().classes("items-center gap-2"):
81
+ ui.label(t.title).classes("text-sm font-medium")
82
+ _status_chip(t.status)
83
+ if active and t.status in ("running", "queued"):
84
+
85
+ async def cancel_this(tid: str = t.task_id) -> None:
86
+ ok = await manager.cancel_task(tid)
87
+ ui.notify(
88
+ f"任务 {tid[:8]} {'已取消' if ok else '取消失败'}",
89
+ color=("primary" if ok else "negative"),
90
+ )
91
+
92
+ ui.button("取消", on_click=cancel_this)
93
+ else:
94
+ ui.button(
95
+ "取消",
96
+ on_click=lambda: ui.notify("任务已结束,无法取消"),
97
+ ).props("disable")
98
+
99
+ # meta grid
100
+ with ui.column().classes("w-full gap-1 mt-2"):
101
+ _meta_row("站点", t.site)
102
+ _meta_row("书号", t.book_id)
103
+ if t.status == "failed" and t.error:
104
+ with ui.row().classes("items-start justify-between w-full"):
105
+ ui.label("错误").classes("text-xs text-grey-7")
106
+ ui.label(t.error).classes("text-xs text-negative q-ml-md")
107
+
108
+ # progress / summary
109
+ with ui.column().classes("w-full mt-2"):
110
+ _progress_block(t)
111
+
112
+
113
+ @ui.page("/progress") # type: ignore[misc]
114
+ def page_progress() -> None:
115
+ navbar("progress")
116
+ ui.label("正在下载 / 历史记录").classes("text-lg")
117
+ setup_dialog()
118
+
119
+ @ui.refreshable # type: ignore[misc]
120
+ def section() -> None:
121
+ s = manager.snapshot()
122
+
123
+ # Active first
124
+ ui.label("运行中 / 等待中").classes("text-base mt-2")
125
+ with ui.card().classes("w-full"):
126
+ running = s["running"]
127
+ pending = s["pending"]
128
+ if not running and not pending:
129
+ ui.label("暂无").classes("text-sm text-grey-6")
130
+ else:
131
+ if running:
132
+ _task_card(running, active=True)
133
+ for t in pending:
134
+ _task_card(t, active=True)
135
+
136
+ # History next
137
+ ui.label("已完成 / 已取消 / 失败").classes("text-base mt-4")
138
+ with ui.card().classes("w-full"):
139
+ if not s["completed"]:
140
+ ui.label("暂无").classes("text-sm text-grey-6")
141
+ else:
142
+ for t in s["completed"]:
143
+ _task_card(t, active=False)
144
+
145
+ # periodic refresh
146
+ ui.timer(0.5, section.refresh)
147
+ section()
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.pages.search
4
+ ---------------------------------
5
+
6
+ Search UI with a settings dropdown, persistent state, and paginated results.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ from collections.abc import Callable
13
+ from math import ceil
14
+ from typing import Any
15
+
16
+ from nicegui import ui
17
+ from nicegui.elements.number import Number
18
+ from nicegui.events import ValueChangeEventArguments
19
+
20
+ from novel_downloader.core import search
21
+ from novel_downloader.models import SearchResult
22
+ from novel_downloader.web.components import navbar
23
+ from novel_downloader.web.services import manager, setup_dialog
24
+
25
+ _SUPPORT_SITES = {
26
+ "aaatxt": "3A电子书",
27
+ "biquge": "笔趣阁",
28
+ "dxmwx": "大熊猫文学网",
29
+ "eightnovel": "无限轻小说",
30
+ "esjzone": "ESJ Zone",
31
+ "hetushu": "和图书",
32
+ "i25zw": "25中文网",
33
+ "ixdzs8": "爱下电子书",
34
+ "jpxs123": "精品小说网",
35
+ "piaotia": "飘天文学网",
36
+ "qbtr": "全本同人小说",
37
+ "qianbi": "铅笔小说",
38
+ "quanben5": "全本小说网",
39
+ "shuhaige": "书海阁小说网",
40
+ "tongrenquan": "同人圈",
41
+ "ttkan": "天天看小说",
42
+ # "wanbengo": "完本神站",
43
+ "xiaoshuowu": "小说屋",
44
+ "xiguashuwu": "西瓜书屋",
45
+ "xs63b": "小说路上",
46
+ # "xshbook": "小说虎",
47
+ }
48
+
49
+ _DEFAULT_TIMEOUT = 10.0
50
+ _DEFAULT_SITE_LIMIT = 30
51
+ _PAGE_SIZE = 20
52
+ _PAGER_WIDTH = 9
53
+
54
+ _STATE: dict[str, dict[str, Any]] = {}
55
+
56
+
57
+ def _get_state() -> dict[str, Any]:
58
+ cid = ui.context.client.id
59
+ if cid not in _STATE:
60
+ _STATE[cid] = {
61
+ "query": "",
62
+ "sites": None, # list[str] | None (None => search all)
63
+ "per_site_limit": _DEFAULT_SITE_LIMIT,
64
+ "timeout": _DEFAULT_TIMEOUT,
65
+ "results": [], # list[SearchResult]
66
+ "page": 1,
67
+ "page_size": _PAGE_SIZE,
68
+ }
69
+ return _STATE[cid]
70
+
71
+
72
+ def _cleanup_state() -> None:
73
+ cid = ui.context.client.id
74
+ _STATE.pop(cid, None)
75
+
76
+
77
+ def _coerce_timeout(inp: Number) -> float:
78
+ v = inp.value
79
+ try:
80
+ v = float(v)
81
+ if v <= 0:
82
+ raise ValueError
83
+ except (TypeError, ValueError):
84
+ ui.notify("超时需 > 0 秒,已重置为 10.0", type="warning")
85
+ v = _DEFAULT_TIMEOUT
86
+ inp.set_value(v)
87
+ inp.sanitize()
88
+ return float(v)
89
+
90
+
91
+ def _coerce_psl(inp: Number) -> int:
92
+ v = inp.value
93
+ try:
94
+ v = int(v)
95
+ if v <= 0:
96
+ raise ValueError
97
+ except (TypeError, ValueError):
98
+ ui.notify("单站条数上限需为正整数,已重置为 5", type="warning")
99
+ v = _DEFAULT_SITE_LIMIT
100
+ inp.set_value(v)
101
+ inp.sanitize()
102
+ return int(v)
103
+
104
+
105
+ def _render_placeholder_cover() -> None:
106
+ with ui.element("div").classes(
107
+ "w-[72px] h-[96px] bg-grey-3 rounded-md flex items-center " "justify-center"
108
+ ):
109
+ ui.icon("book").classes("text-grey-6 text-3xl")
110
+
111
+
112
+ def _render_result_row(r: SearchResult) -> None:
113
+ with (
114
+ ui.card().classes("w-full"),
115
+ ui.row().classes("items-start justify-between w-full gap-3"),
116
+ ):
117
+ cover = (r.get("cover_url") or "").strip()
118
+ if cover.startswith(("http://", "https://")):
119
+ ui.image(cover).classes("w-[72px] h-[96px] object-cover rounded-md")
120
+ else:
121
+ _render_placeholder_cover()
122
+
123
+ with ui.column().classes("gap-1 grow"):
124
+ ui.link(r["title"], r["book_url"], new_tab=True).classes(
125
+ "text-base font-medium"
126
+ )
127
+ ui.label(
128
+ f"{r['author']} · {r['word_count']} · 更新于 {r['update_date']}"
129
+ ).classes("text-xs text-grey-6")
130
+ ui.label(r["latest_chapter"]).classes("text-sm text-grey-7")
131
+ ui.label(f"{r['site']} · ID: {r['book_id']}").classes("text-xs text-grey-5")
132
+
133
+ async def _add_task() -> None:
134
+ title = r["title"]
135
+ ui.notify(f"已添加任务:{title}")
136
+ await manager.add_task(title=title, site=r["site"], book_id=r["book_id"])
137
+
138
+ ui.button("下载", color="primary", on_click=_add_task).props("unelevated")
139
+
140
+
141
+ def _build_settings_dropdown(
142
+ state: dict[str, Any],
143
+ ) -> tuple[Callable[[], list[str] | None], Callable[[], int], Callable[[], float]]:
144
+ """
145
+ Create settings button + anchored menu with initial values from state.
146
+
147
+ Returns a tuple of getter functions:
148
+ - get_sites(): list of site keys, or None if none selected
149
+ - get_psl(): per-site limit (int)
150
+ - get_timeout(): timeout (float)
151
+ """
152
+ site_cbs: dict[str, Any] = {}
153
+
154
+ settings_btn = ui.button("设置").props("outline icon=settings")
155
+ with settings_btn:
156
+ menu = ui.menu().props("no-parent-event")
157
+ with menu:
158
+ ui.label("站点选择").classes("text-sm text-grey-7 q-mb-xs")
159
+
160
+ with ui.row().classes("gap-2"):
161
+
162
+ def _select_all() -> None:
163
+ for cb in site_cbs.values():
164
+ cb.set_value(True)
165
+
166
+ def _clear_all() -> None:
167
+ for cb in site_cbs.values():
168
+ cb.set_value(False)
169
+
170
+ ui.button("全选", on_click=_select_all).props("dense")
171
+ ui.button("清空", on_click=_clear_all).props("dense")
172
+
173
+ ui.separator()
174
+
175
+ with (
176
+ ui.scroll_area().classes("w-[300px] max-h-[260px] q-mt-xs"),
177
+ ui.column().classes("gap-1"),
178
+ ):
179
+ selected = set(state.get("sites") or [])
180
+ for key, label in _SUPPORT_SITES.items():
181
+ site_cbs[key] = ui.checkbox(label, value=(key in selected))
182
+
183
+ ui.separator()
184
+ ui.label("高级设置").classes("text-sm text-grey-7 q-mt-sm")
185
+
186
+ psl_in = (
187
+ ui.number(
188
+ "单站条数上限",
189
+ value=state["per_site_limit"],
190
+ min=1,
191
+ step=1,
192
+ )
193
+ .without_auto_validation()
194
+ .classes("w-[180px]")
195
+ )
196
+ timeout_in = (
197
+ ui.number(
198
+ "超时(秒)",
199
+ value=state["timeout"],
200
+ format="%.1f",
201
+ min=0.1,
202
+ step=0.1,
203
+ )
204
+ .without_auto_validation()
205
+ .classes("w-[180px]")
206
+ )
207
+
208
+ settings_btn.on("click", lambda: menu.open())
209
+
210
+ def _get_sites() -> list[str] | None:
211
+ chosen = [k for k, cb in site_cbs.items() if bool(cb.value)]
212
+ return chosen or None
213
+
214
+ def _get_psl() -> int:
215
+ val = _coerce_psl(psl_in)
216
+ state["per_site_limit"] = val
217
+ return val
218
+
219
+ def _get_timeout() -> float:
220
+ val = _coerce_timeout(timeout_in)
221
+ state["timeout"] = val
222
+ return val
223
+
224
+ return _get_sites, _get_psl, _get_timeout
225
+
226
+
227
+ @ui.page("/") # type: ignore[misc]
228
+ def page_search() -> None:
229
+ navbar("search")
230
+ ui.label("搜索页面").classes("text-lg")
231
+ setup_dialog()
232
+
233
+ state = _get_state()
234
+
235
+ # settings (left) + query (middle) + search (right)
236
+ with ui.row().classes("items-center gap-2 my-2 w-full"):
237
+ get_sites, get_psl, get_timeout = _build_settings_dropdown(state)
238
+
239
+ query_in = (
240
+ ui.input("输入关键字", value=state["query"])
241
+ .props("outlined dense clearable")
242
+ .classes("min-w-[320px] grow")
243
+ )
244
+
245
+ search_btn = ui.button("搜索", color="primary").props("unelevated")
246
+
247
+ # results & pagination container
248
+ list_area = ui.column().classes("w-full")
249
+ pager_area = ui.row().classes("items-center justify-center w-full q-mt-md")
250
+
251
+ @ui.refreshable # type: ignore[misc]
252
+ def render_results() -> None:
253
+ list_area.clear()
254
+ pager_area.clear()
255
+
256
+ results: list[SearchResult] = state["results"]
257
+ total = len(results)
258
+ page_size = int(state["page_size"])
259
+ total_pages = max(1, ceil(total / page_size))
260
+ page = max(1, min(int(state["page"]), total_pages))
261
+ state["page"] = page
262
+
263
+ start = (page - 1) * page_size
264
+ end = min(total, start + page_size)
265
+ current = results[start:end]
266
+
267
+ tip = (
268
+ f"共 {total} 条结果(第 {page}/{total_pages} 页)"
269
+ if state["sites"]
270
+ else f"共 {total} 条结果(第 {page}/{total_pages} 页,已搜索全部站点)"
271
+ )
272
+
273
+ with list_area:
274
+ ui.label(tip).classes("text-sm text-grey-7")
275
+ with ui.column().classes("w-full gap-2"):
276
+ for r in current:
277
+ _render_result_row(r)
278
+
279
+ # pagination (only show if more than 1 page)
280
+ if total_pages > 1:
281
+
282
+ def _on_page_change(e: ValueChangeEventArguments) -> None:
283
+ try:
284
+ state["page"] = int(e.value or 1)
285
+ except Exception:
286
+ state["page"] = 1
287
+ render_results.refresh()
288
+
289
+ with pager_area:
290
+ ui.pagination(
291
+ 1, # min
292
+ total_pages, # max
293
+ direction_links=True,
294
+ value=page,
295
+ on_change=_on_page_change,
296
+ ).props(f"max-pages={_PAGER_WIDTH} boundary-numbers ellipses")
297
+
298
+ async def do_search() -> None:
299
+ q = (query_in.value or "").strip()
300
+ if not q:
301
+ ui.notify("请输入关键词", type="warning")
302
+ return
303
+
304
+ state["query"] = q
305
+ state["sites"] = get_sites()
306
+ per_site_limit = get_psl()
307
+ timeout_val = get_timeout()
308
+
309
+ # perform search
310
+ results = await search(
311
+ keyword=q,
312
+ sites=state["sites"],
313
+ limit=None, # show all
314
+ per_site_limit=per_site_limit,
315
+ timeout=timeout_val,
316
+ )
317
+ state["results"] = results
318
+ state["page"] = 1
319
+ render_results.refresh()
320
+
321
+ search_btn.on("click", do_search)
322
+ query_in.on("keydown.enter", do_search)
323
+
324
+ # initial render
325
+ render_results()
326
+
327
+ # clean up state on disconnect to avoid leaks
328
+ with contextlib.suppress(Exception):
329
+ ui.context.client.on_disconnect(_cleanup_state)
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.services
4
+ -----------------------------
5
+
6
+ Convenience re-exports for web UI services
7
+ """
8
+
9
+ __all__ = [
10
+ "setup_dialog",
11
+ "manager",
12
+ "DownloadTask",
13
+ "Status",
14
+ ]
15
+
16
+ from .client_dialog import setup_dialog
17
+ from .task_manager import DownloadTask, Status, manager
@@ -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