novel-downloader 1.5.0__py3-none-any.whl → 2.0.1__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.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/__init__.py +1 -3
- novel_downloader/cli/clean.py +21 -88
- novel_downloader/cli/config.py +26 -21
- novel_downloader/cli/download.py +79 -66
- novel_downloader/cli/export.py +17 -21
- novel_downloader/cli/main.py +1 -1
- novel_downloader/cli/search.py +62 -65
- novel_downloader/cli/ui.py +156 -0
- novel_downloader/config/__init__.py +8 -5
- novel_downloader/config/adapter.py +206 -209
- novel_downloader/config/{loader.py → file_io.py} +53 -26
- novel_downloader/core/__init__.py +5 -5
- novel_downloader/core/archived/deqixs/fetcher.py +115 -0
- novel_downloader/core/archived/deqixs/parser.py +132 -0
- novel_downloader/core/archived/deqixs/searcher.py +89 -0
- novel_downloader/core/{searchers/qidian.py → archived/qidian/searcher.py} +12 -20
- novel_downloader/core/archived/wanbengo/searcher.py +98 -0
- novel_downloader/core/archived/xshbook/searcher.py +93 -0
- novel_downloader/core/downloaders/__init__.py +3 -24
- novel_downloader/core/downloaders/base.py +49 -23
- novel_downloader/core/downloaders/common.py +191 -137
- novel_downloader/core/downloaders/qianbi.py +187 -146
- novel_downloader/core/downloaders/qidian.py +187 -141
- novel_downloader/core/downloaders/registry.py +4 -2
- novel_downloader/core/downloaders/signals.py +46 -0
- novel_downloader/core/exporters/__init__.py +3 -20
- novel_downloader/core/exporters/base.py +33 -37
- novel_downloader/core/exporters/common/__init__.py +1 -2
- novel_downloader/core/exporters/common/epub.py +15 -10
- novel_downloader/core/exporters/common/main_exporter.py +19 -12
- novel_downloader/core/exporters/common/txt.py +17 -12
- novel_downloader/core/exporters/epub_util.py +59 -29
- novel_downloader/core/exporters/linovelib/__init__.py +1 -0
- novel_downloader/core/exporters/linovelib/epub.py +23 -25
- novel_downloader/core/exporters/linovelib/main_exporter.py +8 -12
- novel_downloader/core/exporters/linovelib/txt.py +20 -14
- novel_downloader/core/exporters/qidian.py +2 -8
- novel_downloader/core/exporters/registry.py +4 -2
- novel_downloader/core/exporters/txt_util.py +7 -7
- novel_downloader/core/fetchers/__init__.py +54 -48
- novel_downloader/core/fetchers/aaatxt.py +83 -0
- novel_downloader/core/fetchers/{biquge/session.py → b520.py} +6 -11
- novel_downloader/core/fetchers/{base/session.py → base.py} +37 -46
- novel_downloader/core/fetchers/{biquge/browser.py → biquyuedu.py} +12 -17
- novel_downloader/core/fetchers/dxmwx.py +110 -0
- novel_downloader/core/fetchers/eightnovel.py +139 -0
- novel_downloader/core/fetchers/{esjzone/session.py → esjzone.py} +19 -12
- novel_downloader/core/fetchers/guidaye.py +85 -0
- novel_downloader/core/fetchers/hetushu.py +92 -0
- novel_downloader/core/fetchers/{qianbi/browser.py → i25zw.py} +19 -28
- novel_downloader/core/fetchers/ixdzs8.py +113 -0
- novel_downloader/core/fetchers/jpxs123.py +101 -0
- novel_downloader/core/fetchers/lewenn.py +83 -0
- novel_downloader/core/fetchers/{linovelib/session.py → linovelib.py} +12 -13
- novel_downloader/core/fetchers/piaotia.py +105 -0
- novel_downloader/core/fetchers/qbtr.py +101 -0
- novel_downloader/core/fetchers/{qianbi/session.py → qianbi.py} +5 -10
- novel_downloader/core/fetchers/{qidian/session.py → qidian.py} +56 -64
- novel_downloader/core/fetchers/quanben5.py +92 -0
- novel_downloader/core/fetchers/{base/rate_limiter.py → rate_limiter.py} +2 -2
- novel_downloader/core/fetchers/registry.py +5 -16
- novel_downloader/core/fetchers/{sfacg/session.py → sfacg.py} +7 -10
- novel_downloader/core/fetchers/shencou.py +106 -0
- novel_downloader/core/fetchers/shuhaige.py +84 -0
- novel_downloader/core/fetchers/tongrenquan.py +84 -0
- novel_downloader/core/fetchers/ttkan.py +95 -0
- novel_downloader/core/fetchers/wanbengo.py +83 -0
- novel_downloader/core/fetchers/xiaoshuowu.py +106 -0
- novel_downloader/core/fetchers/xiguashuwu.py +177 -0
- novel_downloader/core/fetchers/xs63b.py +171 -0
- novel_downloader/core/fetchers/xshbook.py +85 -0
- novel_downloader/core/fetchers/{yamibo/session.py → yamibo.py} +19 -12
- novel_downloader/core/fetchers/yibige.py +114 -0
- novel_downloader/core/interfaces/__init__.py +1 -9
- novel_downloader/core/interfaces/downloader.py +6 -2
- novel_downloader/core/interfaces/exporter.py +7 -7
- novel_downloader/core/interfaces/fetcher.py +6 -19
- novel_downloader/core/interfaces/parser.py +7 -8
- novel_downloader/core/interfaces/searcher.py +9 -1
- novel_downloader/core/parsers/__init__.py +49 -12
- novel_downloader/core/parsers/aaatxt.py +132 -0
- novel_downloader/core/parsers/b520.py +116 -0
- novel_downloader/core/parsers/base.py +64 -12
- novel_downloader/core/parsers/biquyuedu.py +133 -0
- novel_downloader/core/parsers/dxmwx.py +162 -0
- novel_downloader/core/parsers/eightnovel.py +224 -0
- novel_downloader/core/parsers/esjzone.py +64 -69
- novel_downloader/core/parsers/guidaye.py +128 -0
- novel_downloader/core/parsers/hetushu.py +139 -0
- novel_downloader/core/parsers/i25zw.py +137 -0
- novel_downloader/core/parsers/ixdzs8.py +186 -0
- novel_downloader/core/parsers/jpxs123.py +137 -0
- novel_downloader/core/parsers/lewenn.py +142 -0
- novel_downloader/core/parsers/linovelib.py +48 -64
- novel_downloader/core/parsers/piaotia.py +189 -0
- novel_downloader/core/parsers/qbtr.py +136 -0
- novel_downloader/core/parsers/qianbi.py +48 -50
- novel_downloader/core/parsers/qidian/main_parser.py +756 -48
- novel_downloader/core/parsers/qidian/utils/__init__.py +3 -21
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +1 -1
- novel_downloader/core/parsers/qidian/utils/node_decryptor.py +4 -4
- novel_downloader/core/parsers/quanben5.py +103 -0
- novel_downloader/core/parsers/registry.py +5 -16
- novel_downloader/core/parsers/sfacg.py +38 -45
- novel_downloader/core/parsers/shencou.py +215 -0
- novel_downloader/core/parsers/shuhaige.py +111 -0
- novel_downloader/core/parsers/tongrenquan.py +116 -0
- novel_downloader/core/parsers/ttkan.py +132 -0
- novel_downloader/core/parsers/wanbengo.py +191 -0
- novel_downloader/core/parsers/xiaoshuowu.py +173 -0
- novel_downloader/core/parsers/xiguashuwu.py +429 -0
- novel_downloader/core/parsers/xs63b.py +161 -0
- novel_downloader/core/parsers/xshbook.py +134 -0
- novel_downloader/core/parsers/yamibo.py +87 -131
- novel_downloader/core/parsers/yibige.py +166 -0
- novel_downloader/core/searchers/__init__.py +34 -3
- novel_downloader/core/searchers/aaatxt.py +107 -0
- novel_downloader/core/searchers/{biquge.py → b520.py} +29 -28
- novel_downloader/core/searchers/base.py +112 -36
- novel_downloader/core/searchers/dxmwx.py +105 -0
- novel_downloader/core/searchers/eightnovel.py +84 -0
- novel_downloader/core/searchers/esjzone.py +43 -25
- novel_downloader/core/searchers/hetushu.py +92 -0
- novel_downloader/core/searchers/i25zw.py +93 -0
- novel_downloader/core/searchers/ixdzs8.py +107 -0
- novel_downloader/core/searchers/jpxs123.py +107 -0
- novel_downloader/core/searchers/piaotia.py +100 -0
- novel_downloader/core/searchers/qbtr.py +106 -0
- novel_downloader/core/searchers/qianbi.py +74 -40
- novel_downloader/core/searchers/quanben5.py +144 -0
- novel_downloader/core/searchers/registry.py +24 -8
- novel_downloader/core/searchers/shuhaige.py +124 -0
- novel_downloader/core/searchers/tongrenquan.py +110 -0
- novel_downloader/core/searchers/ttkan.py +92 -0
- novel_downloader/core/searchers/xiaoshuowu.py +122 -0
- novel_downloader/core/searchers/xiguashuwu.py +95 -0
- novel_downloader/core/searchers/xs63b.py +104 -0
- novel_downloader/locales/en.json +34 -85
- novel_downloader/locales/zh.json +35 -86
- novel_downloader/models/__init__.py +21 -22
- novel_downloader/models/book.py +44 -0
- novel_downloader/models/config.py +4 -37
- novel_downloader/models/login.py +1 -1
- novel_downloader/models/search.py +5 -0
- novel_downloader/resources/config/settings.toml +8 -70
- novel_downloader/resources/json/xiguashuwu.json +718 -0
- novel_downloader/utils/__init__.py +13 -24
- novel_downloader/utils/chapter_storage.py +5 -5
- novel_downloader/utils/constants.py +4 -31
- novel_downloader/utils/cookies.py +38 -35
- novel_downloader/utils/crypto_utils/__init__.py +7 -0
- novel_downloader/utils/crypto_utils/aes_util.py +90 -0
- novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
- novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
- novel_downloader/utils/crypto_utils/rc4.py +54 -0
- novel_downloader/utils/epub/__init__.py +3 -4
- novel_downloader/utils/epub/builder.py +6 -6
- novel_downloader/utils/epub/constants.py +62 -21
- novel_downloader/utils/epub/documents.py +95 -201
- novel_downloader/utils/epub/models.py +8 -22
- novel_downloader/utils/epub/utils.py +73 -106
- novel_downloader/utils/file_utils/__init__.py +2 -23
- novel_downloader/utils/file_utils/io.py +53 -188
- novel_downloader/utils/file_utils/normalize.py +1 -7
- novel_downloader/utils/file_utils/sanitize.py +4 -15
- novel_downloader/utils/fontocr/__init__.py +5 -14
- novel_downloader/utils/fontocr/core.py +216 -0
- novel_downloader/utils/fontocr/loader.py +50 -0
- novel_downloader/utils/logger.py +81 -65
- novel_downloader/utils/network.py +17 -41
- novel_downloader/utils/state.py +4 -90
- novel_downloader/utils/text_utils/__init__.py +1 -7
- novel_downloader/utils/text_utils/diff_display.py +5 -7
- novel_downloader/utils/text_utils/text_cleaner.py +39 -30
- novel_downloader/utils/text_utils/truncate_utils.py +3 -14
- novel_downloader/utils/time_utils/__init__.py +5 -11
- novel_downloader/utils/time_utils/datetime_utils.py +20 -29
- novel_downloader/utils/time_utils/sleep_utils.py +55 -49
- novel_downloader/web/__init__.py +13 -0
- novel_downloader/web/components/__init__.py +11 -0
- novel_downloader/web/components/navigation.py +35 -0
- novel_downloader/web/main.py +66 -0
- novel_downloader/web/pages/__init__.py +17 -0
- novel_downloader/web/pages/download.py +78 -0
- novel_downloader/web/pages/progress.py +147 -0
- novel_downloader/web/pages/search.py +329 -0
- novel_downloader/web/services/__init__.py +17 -0
- novel_downloader/web/services/client_dialog.py +164 -0
- novel_downloader/web/services/cred_broker.py +113 -0
- novel_downloader/web/services/cred_models.py +35 -0
- novel_downloader/web/services/task_manager.py +264 -0
- novel_downloader-2.0.1.dist-info/METADATA +172 -0
- novel_downloader-2.0.1.dist-info/RECORD +206 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/entry_points.txt +1 -1
- novel_downloader/core/downloaders/biquge.py +0 -29
- novel_downloader/core/downloaders/esjzone.py +0 -29
- novel_downloader/core/downloaders/linovelib.py +0 -29
- novel_downloader/core/downloaders/sfacg.py +0 -29
- novel_downloader/core/downloaders/yamibo.py +0 -29
- novel_downloader/core/exporters/biquge.py +0 -22
- novel_downloader/core/exporters/esjzone.py +0 -22
- novel_downloader/core/exporters/qianbi.py +0 -22
- novel_downloader/core/exporters/sfacg.py +0 -22
- novel_downloader/core/exporters/yamibo.py +0 -22
- novel_downloader/core/fetchers/base/__init__.py +0 -14
- novel_downloader/core/fetchers/base/browser.py +0 -422
- novel_downloader/core/fetchers/biquge/__init__.py +0 -14
- novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
- novel_downloader/core/fetchers/esjzone/browser.py +0 -209
- novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
- novel_downloader/core/fetchers/linovelib/browser.py +0 -198
- novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
- novel_downloader/core/fetchers/qidian/__init__.py +0 -14
- novel_downloader/core/fetchers/qidian/browser.py +0 -326
- novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
- novel_downloader/core/fetchers/sfacg/browser.py +0 -194
- novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
- novel_downloader/core/fetchers/yamibo/browser.py +0 -234
- novel_downloader/core/parsers/biquge.py +0 -139
- novel_downloader/core/parsers/qidian/book_info_parser.py +0 -90
- novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -528
- novel_downloader/core/parsers/qidian/chapter_normal.py +0 -157
- novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
- novel_downloader/core/parsers/qidian/utils/helpers.py +0 -114
- novel_downloader/models/chapter.py +0 -25
- novel_downloader/models/types.py +0 -13
- novel_downloader/tui/__init__.py +0 -7
- novel_downloader/tui/app.py +0 -32
- novel_downloader/tui/main.py +0 -17
- novel_downloader/tui/screens/__init__.py +0 -14
- novel_downloader/tui/screens/home.py +0 -198
- novel_downloader/tui/screens/login.py +0 -74
- novel_downloader/tui/styles/home_layout.tcss +0 -79
- novel_downloader/tui/widgets/richlog_handler.py +0 -24
- novel_downloader/utils/cache.py +0 -24
- novel_downloader/utils/crypto_utils.py +0 -71
- novel_downloader/utils/fontocr/hash_store.py +0 -280
- novel_downloader/utils/fontocr/hash_utils.py +0 -103
- novel_downloader/utils/fontocr/model_loader.py +0 -69
- novel_downloader/utils/fontocr/ocr_v1.py +0 -315
- novel_downloader/utils/fontocr/ocr_v2.py +0 -764
- novel_downloader/utils/fontocr/ocr_v3.py +0 -744
- novel_downloader-1.5.0.dist-info/METADATA +0 -196
- novel_downloader-1.5.0.dist-info/RECORD +0 -164
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/WHEEL +0 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.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()
|