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,35 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.components.navigation
4
+ ------------------------------------------
5
+
6
+ A tiny NiceGUI component that renders the app's top navigation bar
7
+ """
8
+
9
+ from nicegui import ui
10
+
11
+
12
+ def navbar(active: str) -> None:
13
+ """
14
+ Render the site-wide navigation header.
15
+
16
+ :param active: Key of the current page to highlight.
17
+ """
18
+ with (
19
+ ui.header().classes("px-3 items-center justify-between bg-primary text-white"),
20
+ ui.row().classes("items-center gap-2 flex-wrap"),
21
+ ):
22
+ _nav_btn("搜索", "/", active == "search", icon="search")
23
+ _nav_btn("下载", "/download", active == "download", icon="download")
24
+ _nav_btn("正在下载", "/progress", active == "progress", icon="cloud_download")
25
+
26
+
27
+ def _nav_btn(label: str, path: str, is_active: bool, icon: str | None = None) -> None:
28
+ if is_active:
29
+ ui.button(label, icon=icon, on_click=lambda: ui.navigate.to(path)).props(
30
+ "unelevated color=white text-color=primary"
31
+ )
32
+ else:
33
+ ui.button(label, icon=icon, on_click=lambda: ui.navigate.to(path)).props(
34
+ "flat text-color=white"
35
+ )
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.main
4
+ -------------------------
5
+
6
+ Novel Downloader web UI (NiceGUI).
7
+
8
+ This entry point starts the local server and registers the app's pages.
9
+ """
10
+
11
+ import argparse
12
+ from pathlib import Path
13
+
14
+ from nicegui import app, ui
15
+
16
+ import novel_downloader.web.pages # noqa: F401
17
+ from novel_downloader.config import get_config_value
18
+ from novel_downloader.utils.logger import setup_logging
19
+
20
+
21
+ def mount_exports() -> None:
22
+ output_dir = get_config_value(["general", "output_dir"], "./downloads")
23
+ out = Path(output_dir).expanduser().resolve()
24
+ out.mkdir(parents=True, exist_ok=True)
25
+ # serves /download/<filename> from the export dir
26
+ app.add_static_files("/download", local_directory=out)
27
+
28
+
29
+ def web_main() -> None:
30
+ p = argparse.ArgumentParser(
31
+ description="Novel Downloader web UI.",
32
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
33
+ )
34
+ p.add_argument(
35
+ "--listen",
36
+ choices=["local", "public"],
37
+ default="local",
38
+ help=(
39
+ "Bind address mode: 'local' binds to 127.0.0.1; "
40
+ "'public' binds to 0.0.0.0."
41
+ ),
42
+ )
43
+ p.add_argument(
44
+ "--port",
45
+ type=int,
46
+ default=8080,
47
+ help="TCP port to serve the app on.",
48
+ )
49
+ p.add_argument(
50
+ "--reload",
51
+ action="store_true",
52
+ help="Enable autoreload on code changes (development).",
53
+ )
54
+ args = p.parse_args()
55
+
56
+ host = "127.0.0.1" if args.listen == "local" else "0.0.0.0"
57
+
58
+ log_level = get_config_value(["general", "debug", "log_level"], "INFO")
59
+ setup_logging(log_level=log_level)
60
+
61
+ app.on_startup(mount_exports)
62
+ ui.run(host=host, port=args.port, reload=args.reload)
63
+
64
+
65
+ if __name__ in {"__main__", "__mp_main__"}:
66
+ web_main()
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.pages
4
+ --------------------------
5
+
6
+ NiceGUI page registrations; importing this package exposes and registers all routes.
7
+ """
8
+
9
+ __all__ = [
10
+ "page_download", # /download
11
+ "page_progress", # /progress
12
+ "page_search", # /
13
+ ]
14
+
15
+ from .download import page_download
16
+ from .progress import page_progress
17
+ from .search import page_search
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.web.pages.download
4
+ -----------------------------------
5
+
6
+ """
7
+
8
+ from nicegui import ui
9
+
10
+ from novel_downloader.web.components import navbar
11
+ from novel_downloader.web.services import manager, setup_dialog
12
+
13
+ _SUPPORT_SITES = {
14
+ "aaatxt": "3A电子书 (aaatxt)",
15
+ "biquge": "笔趣阁 (biquge)",
16
+ "biquyuedu": "精彩小说 (biquyuedu)",
17
+ "dxmwx": "大熊猫文学网 (dxmwx)",
18
+ "eightnovel": "无限轻小说 (8novel)",
19
+ "esjzone": "ESJ Zone (esjzone)",
20
+ "guidaye": "名著阅读 (guidaye)",
21
+ "hetushu": "和图书 (hetushu)",
22
+ "i25zw": "25中文网 (i25zw)",
23
+ "ixdzs8": "爱下电子书 (ixdzs8)",
24
+ "jpxs123": "精品小说网 (jpxs123)",
25
+ "lewenn": "乐文小说网 (lewenn)",
26
+ "linovelib": "哔哩轻小说 (linovelib)",
27
+ "piaotia": "飘天文学网 (piaotia)",
28
+ "qbtr": "全本同人小说 (qbtr)",
29
+ "qianbi": "铅笔小说 (qianbi)",
30
+ "qidian": "起点中文网 (qidian)",
31
+ "quanben5": "全本小说网 (quanben5)",
32
+ "sfacg": "SF轻小说 (sfacg)",
33
+ "shencou": "神凑轻小说 (shencou)",
34
+ "shuhaige": "书海阁小说网 (shuhaige)",
35
+ "tongrenquan": "同人圈 (tongrenquan)",
36
+ "ttkan": "天天看小说 (ttkan)",
37
+ "wanbengo": "完本神站 (wanbengo)",
38
+ "xiaoshuowu": "小说屋 (xiaoshuowu)",
39
+ "xiguashuwu": "西瓜书屋 (xiguashuwu)",
40
+ "xs63b": "小说路上 (xs63b)",
41
+ "xshbook": "小说虎 (xshbook)",
42
+ "yamibo": "百合会 (yamibo)",
43
+ "yibige": "一笔阁 (yibige)",
44
+ }
45
+ _DEFAULT_SITE = "qidian"
46
+
47
+
48
+ @ui.page("/download") # type: ignore[misc]
49
+ def page_download() -> None:
50
+ navbar("download")
51
+ ui.label("下载界面").classes("text-lg")
52
+ setup_dialog()
53
+
54
+ with ui.card().classes("max-w-[600px]"):
55
+ site = ui.select(
56
+ _SUPPORT_SITES,
57
+ value=_DEFAULT_SITE,
58
+ label="站点",
59
+ with_input=True,
60
+ ).classes("w-full")
61
+
62
+ book_id = ui.input("书籍ID").props("outlined dense").classes("w-full")
63
+
64
+ async def add_task() -> None:
65
+ bid = (book_id.value or "").strip()
66
+ if not bid:
67
+ ui.notify("请输入书籍ID", type="warning")
68
+ return
69
+ title = f"{site.value} (id = {bid})"
70
+ ui.notify(f"已添加任务: {title}")
71
+ await manager.add_task(title=title, site=str(site.value), book_id=bid)
72
+
73
+ with ui.row().classes("justify-end w-full"):
74
+ ui.button(
75
+ "添加到下载队列",
76
+ on_click=add_task,
77
+ color="primary",
78
+ ).props("unelevated")
@@ -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