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