novel-downloader 1.2.2__py3-none-any.whl → 1.3.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 -2
- novel_downloader/cli/__init__.py +0 -1
- novel_downloader/cli/clean.py +2 -10
- novel_downloader/cli/download.py +16 -22
- novel_downloader/cli/interactive.py +0 -1
- novel_downloader/cli/main.py +1 -3
- novel_downloader/cli/settings.py +8 -8
- novel_downloader/config/__init__.py +0 -1
- novel_downloader/config/adapter.py +32 -27
- novel_downloader/config/loader.py +116 -108
- novel_downloader/config/models.py +35 -29
- novel_downloader/config/site_rules.py +2 -4
- novel_downloader/core/__init__.py +0 -1
- novel_downloader/core/downloaders/__init__.py +4 -4
- novel_downloader/core/downloaders/base/__init__.py +14 -0
- novel_downloader/core/downloaders/{base_async_downloader.py → base/base_async.py} +49 -53
- novel_downloader/core/downloaders/{base_downloader.py → base/base_sync.py} +64 -43
- novel_downloader/core/downloaders/biquge/__init__.py +12 -0
- novel_downloader/core/downloaders/biquge/biquge_sync.py +25 -0
- novel_downloader/core/downloaders/common/__init__.py +14 -0
- novel_downloader/core/downloaders/{common_asynb_downloader.py → common/common_async.py} +42 -33
- novel_downloader/core/downloaders/{common_downloader.py → common/common_sync.py} +33 -21
- novel_downloader/core/downloaders/qidian/__init__.py +10 -0
- novel_downloader/core/downloaders/{qidian_downloader.py → qidian/qidian_sync.py} +79 -62
- novel_downloader/core/factory/__init__.py +4 -5
- novel_downloader/core/factory/{downloader_factory.py → downloader.py} +25 -26
- novel_downloader/core/factory/{parser_factory.py → parser.py} +12 -14
- novel_downloader/core/factory/{requester_factory.py → requester.py} +29 -16
- novel_downloader/core/factory/{saver_factory.py → saver.py} +4 -9
- novel_downloader/core/interfaces/__init__.py +8 -9
- novel_downloader/core/interfaces/{async_downloader_protocol.py → async_downloader.py} +4 -5
- novel_downloader/core/interfaces/{async_requester_protocol.py → async_requester.py} +23 -12
- novel_downloader/core/interfaces/{parser_protocol.py → parser.py} +11 -6
- novel_downloader/core/interfaces/{saver_protocol.py → saver.py} +2 -3
- novel_downloader/core/interfaces/{downloader_protocol.py → sync_downloader.py} +6 -7
- novel_downloader/core/interfaces/{requester_protocol.py → sync_requester.py} +31 -17
- novel_downloader/core/parsers/__init__.py +5 -4
- novel_downloader/core/parsers/{base_parser.py → base.py} +18 -9
- novel_downloader/core/parsers/biquge/__init__.py +10 -0
- novel_downloader/core/parsers/biquge/main_parser.py +126 -0
- novel_downloader/core/parsers/{common_parser → common}/__init__.py +2 -3
- novel_downloader/core/parsers/{common_parser → common}/helper.py +13 -13
- novel_downloader/core/parsers/{common_parser → common}/main_parser.py +15 -9
- novel_downloader/core/parsers/{qidian_parser → qidian}/__init__.py +2 -3
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/__init__.py +2 -3
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_encrypted.py +40 -48
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_normal.py +17 -21
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_router.py +10 -9
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/main_parser.py +14 -10
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/__init__.py +2 -3
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_encrypted.py +36 -44
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_normal.py +19 -23
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_router.py +10 -9
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/main_parser.py +14 -10
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/node_decryptor.py +7 -10
- novel_downloader/core/parsers/{qidian_parser → qidian}/shared/__init__.py +2 -3
- novel_downloader/core/parsers/{qidian_parser → qidian}/shared/book_info_parser.py +5 -6
- novel_downloader/core/parsers/{qidian_parser → qidian}/shared/helpers.py +7 -8
- novel_downloader/core/requesters/__init__.py +9 -5
- novel_downloader/core/requesters/base/__init__.py +16 -0
- novel_downloader/core/requesters/{base_async_session.py → base/async_session.py} +177 -73
- novel_downloader/core/requesters/base/browser.py +340 -0
- novel_downloader/core/requesters/base/session.py +364 -0
- novel_downloader/core/requesters/biquge/__init__.py +12 -0
- novel_downloader/core/requesters/biquge/session.py +90 -0
- novel_downloader/core/requesters/{common_requester → common}/__init__.py +4 -5
- novel_downloader/core/requesters/common/async_session.py +96 -0
- novel_downloader/core/requesters/common/session.py +113 -0
- novel_downloader/core/requesters/qidian/__init__.py +21 -0
- novel_downloader/core/requesters/qidian/broswer.py +306 -0
- novel_downloader/core/requesters/qidian/session.py +287 -0
- novel_downloader/core/savers/__init__.py +5 -3
- novel_downloader/core/savers/{base_saver.py → base.py} +12 -13
- novel_downloader/core/savers/biquge.py +25 -0
- novel_downloader/core/savers/{common_saver → common}/__init__.py +2 -3
- novel_downloader/core/savers/{common_saver/common_epub.py → common/epub.py} +23 -51
- novel_downloader/core/savers/{common_saver → common}/main_saver.py +43 -9
- novel_downloader/core/savers/{common_saver/common_txt.py → common/txt.py} +16 -46
- novel_downloader/core/savers/epub_utils/__init__.py +0 -1
- novel_downloader/core/savers/epub_utils/css_builder.py +13 -7
- novel_downloader/core/savers/epub_utils/initializer.py +4 -5
- novel_downloader/core/savers/epub_utils/text_to_html.py +2 -3
- novel_downloader/core/savers/epub_utils/volume_intro.py +1 -3
- novel_downloader/core/savers/{qidian_saver.py → qidian.py} +12 -6
- novel_downloader/locales/en.json +8 -4
- novel_downloader/locales/zh.json +5 -1
- novel_downloader/resources/config/settings.toml +88 -0
- novel_downloader/utils/cache.py +2 -2
- novel_downloader/utils/chapter_storage.py +340 -0
- novel_downloader/utils/constants.py +6 -4
- novel_downloader/utils/crypto_utils.py +3 -3
- novel_downloader/utils/file_utils/__init__.py +0 -1
- novel_downloader/utils/file_utils/io.py +12 -17
- novel_downloader/utils/file_utils/normalize.py +1 -3
- novel_downloader/utils/file_utils/sanitize.py +2 -9
- novel_downloader/utils/fontocr/__init__.py +0 -1
- novel_downloader/utils/fontocr/ocr_v1.py +19 -22
- novel_downloader/utils/fontocr/ocr_v2.py +147 -60
- novel_downloader/utils/hash_store.py +19 -20
- novel_downloader/utils/hash_utils.py +0 -1
- novel_downloader/utils/i18n.py +3 -4
- novel_downloader/utils/logger.py +5 -6
- novel_downloader/utils/model_loader.py +5 -8
- novel_downloader/utils/network.py +9 -10
- novel_downloader/utils/state.py +6 -7
- novel_downloader/utils/text_utils/__init__.py +0 -1
- novel_downloader/utils/text_utils/chapter_formatting.py +2 -7
- novel_downloader/utils/text_utils/diff_display.py +0 -1
- novel_downloader/utils/text_utils/font_mapping.py +1 -4
- novel_downloader/utils/text_utils/text_cleaning.py +0 -1
- novel_downloader/utils/time_utils/__init__.py +0 -1
- novel_downloader/utils/time_utils/datetime_utils.py +8 -10
- novel_downloader/utils/time_utils/sleep_utils.py +1 -3
- {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.0.dist-info}/METADATA +14 -17
- novel_downloader-1.3.0.dist-info/RECORD +127 -0
- {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.0.dist-info}/WHEEL +1 -1
- novel_downloader/core/requesters/base_browser.py +0 -214
- novel_downloader/core/requesters/base_session.py +0 -246
- novel_downloader/core/requesters/common_requester/common_async_session.py +0 -98
- novel_downloader/core/requesters/common_requester/common_session.py +0 -126
- novel_downloader/core/requesters/qidian_requester/__init__.py +0 -22
- novel_downloader/core/requesters/qidian_requester/qidian_broswer.py +0 -396
- novel_downloader/core/requesters/qidian_requester/qidian_session.py +0 -202
- novel_downloader/resources/config/settings.yaml +0 -76
- novel_downloader-1.2.2.dist-info/RECORD +0 -115
- {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.0.dist-info}/entry_points.txt +0 -0
- {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.0.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,4 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
|
-
# -*- coding: utf-8 -*-
|
3
2
|
"""
|
4
3
|
novel_downloader.utils.hash_store
|
5
4
|
---------------------------------
|
@@ -11,8 +10,8 @@ Supports loading/saving to .json or .npy, and basic CRUD + search.
|
|
11
10
|
import heapq
|
12
11
|
import json
|
13
12
|
import logging
|
13
|
+
from collections.abc import Callable
|
14
14
|
from pathlib import Path
|
15
|
-
from typing import Callable, Dict, List, Optional, Set, Tuple, Union
|
16
15
|
|
17
16
|
import numpy as np
|
18
17
|
from PIL import Image
|
@@ -33,7 +32,7 @@ class _BKNode:
|
|
33
32
|
|
34
33
|
def __init__(self, value: int):
|
35
34
|
self.value = value
|
36
|
-
self.children:
|
35
|
+
self.children: dict[int, _BKNode] = {}
|
37
36
|
|
38
37
|
def add(self, h: int, dist_fn: Callable[[int, int], int]) -> None:
|
39
38
|
d = dist_fn(h, self.value)
|
@@ -48,12 +47,12 @@ class _BKNode:
|
|
48
47
|
target: int,
|
49
48
|
threshold: int,
|
50
49
|
dist_fn: Callable[[int, int], int],
|
51
|
-
) ->
|
50
|
+
) -> list[tuple[int, int]]:
|
52
51
|
"""
|
53
52
|
Recursively collect (value, dist) pairs within threshold.
|
54
53
|
"""
|
55
54
|
d0 = dist_fn(target, self.value)
|
56
|
-
matches:
|
55
|
+
matches: list[tuple[int, int]] = []
|
57
56
|
if d0 <= threshold:
|
58
57
|
matches.append((self.value, d0))
|
59
58
|
# Only children whose edge-dist \in [d0-threshold, d0+threshold]
|
@@ -76,7 +75,7 @@ class ImageHashStore:
|
|
76
75
|
|
77
76
|
def __init__(
|
78
77
|
self,
|
79
|
-
path:
|
78
|
+
path: str | Path = HASH_STORE_FILE,
|
80
79
|
auto_save: bool = False,
|
81
80
|
hash_func: Callable[[Image.Image], int] = phash,
|
82
81
|
ham_dist: Callable[[int, int], int] = fast_hamming_distance,
|
@@ -89,11 +88,11 @@ class ImageHashStore:
|
|
89
88
|
self._th = threshold
|
90
89
|
|
91
90
|
# label -> set of hashes
|
92
|
-
self._hash:
|
91
|
+
self._hash: dict[str, set[int]] = {}
|
93
92
|
# hash -> list of labels (for reverse lookup)
|
94
|
-
self._hash_to_labels:
|
93
|
+
self._hash_to_labels: dict[int, list[str]] = {}
|
95
94
|
# root of BK-Tree (or None if empty)
|
96
|
-
self._bk_root:
|
95
|
+
self._bk_root: _BKNode | None = None
|
97
96
|
|
98
97
|
self.load()
|
99
98
|
|
@@ -155,7 +154,7 @@ class ImageHashStore:
|
|
155
154
|
if self._auto:
|
156
155
|
self.save()
|
157
156
|
|
158
|
-
def add_image(self, img_path:
|
157
|
+
def add_image(self, img_path: str | Path, label: str) -> int:
|
159
158
|
"""
|
160
159
|
Compute hash for the given image and add it under `label`.
|
161
160
|
Updates BK-Tree index incrementally.
|
@@ -173,7 +172,7 @@ class ImageHashStore:
|
|
173
172
|
self._maybe_save()
|
174
173
|
return h
|
175
174
|
|
176
|
-
def add_from_map(self, map_path:
|
175
|
+
def add_from_map(self, map_path: str | Path) -> None:
|
177
176
|
"""
|
178
177
|
Load a JSON file of the form { "image_path": "label", ... }
|
179
178
|
and add each entry.
|
@@ -191,11 +190,11 @@ class ImageHashStore:
|
|
191
190
|
)
|
192
191
|
continue
|
193
192
|
|
194
|
-
def labels(self) ->
|
193
|
+
def labels(self) -> list[str]:
|
195
194
|
"""Return a sorted list of all labels in the store."""
|
196
195
|
return sorted(self._hash.keys())
|
197
196
|
|
198
|
-
def hashes(self, label: str) ->
|
197
|
+
def hashes(self, label: str) -> set[int]:
|
199
198
|
"""Return the set of hashes for a given `label` (empty set if none)."""
|
200
199
|
return set(self._hash.get(label, ()))
|
201
200
|
|
@@ -206,7 +205,7 @@ class ImageHashStore:
|
|
206
205
|
logger.debug("[ImageHashStore] Removed label '%s'", label)
|
207
206
|
self._maybe_save()
|
208
207
|
|
209
|
-
def remove_hash(self, label: str, this:
|
208
|
+
def remove_hash(self, label: str, this: int | str | Path) -> bool:
|
210
209
|
"""
|
211
210
|
Remove a specific hash under `label`.
|
212
211
|
`this` can be:
|
@@ -218,7 +217,7 @@ class ImageHashStore:
|
|
218
217
|
return False
|
219
218
|
|
220
219
|
h = None
|
221
|
-
if isinstance(this, (str
|
220
|
+
if isinstance(this, (str | Path)):
|
222
221
|
try:
|
223
222
|
img = Image.open(this).convert("L")
|
224
223
|
h = self._hf(img)
|
@@ -239,10 +238,10 @@ class ImageHashStore:
|
|
239
238
|
|
240
239
|
def query(
|
241
240
|
self,
|
242
|
-
target:
|
241
|
+
target: int | str | Path | Image.Image,
|
243
242
|
k: int = 1,
|
244
|
-
threshold:
|
245
|
-
) ->
|
243
|
+
threshold: int | None = None,
|
244
|
+
) -> list[tuple[str, float]]:
|
246
245
|
"""
|
247
246
|
Find up to `k` distinct labels whose stored hashes are most similar
|
248
247
|
to `target` within `threshold`. Returns a list of (label, score),
|
@@ -259,7 +258,7 @@ class ImageHashStore:
|
|
259
258
|
if isinstance(target, Image.Image):
|
260
259
|
img = target.convert("L")
|
261
260
|
thash = self._hf(img)
|
262
|
-
elif isinstance(target, (str
|
261
|
+
elif isinstance(target, (str | Path)):
|
263
262
|
img = Image.open(target).convert("L")
|
264
263
|
thash = self._hf(img)
|
265
264
|
else:
|
@@ -272,7 +271,7 @@ class ImageHashStore:
|
|
272
271
|
matches = self._bk_root.query(thash, threshold, self._hd)
|
273
272
|
|
274
273
|
# collapse to one best dist per label
|
275
|
-
best_per_label:
|
274
|
+
best_per_label: dict[str, float] = {}
|
276
275
|
h2l = self._hash_to_labels
|
277
276
|
for h, dist in matches:
|
278
277
|
for lbl in h2l.get(h, ()):
|
novel_downloader/utils/i18n.py
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
|
-
# -*- coding: utf-8 -*-
|
3
2
|
"""
|
4
3
|
novel_downloader.utils.i18n
|
5
4
|
---------------------------
|
@@ -8,17 +7,17 @@ Multilingual text dictionary and utility for CLI and interactive mode.
|
|
8
7
|
"""
|
9
8
|
|
10
9
|
import json
|
11
|
-
from typing import Any
|
10
|
+
from typing import Any
|
12
11
|
|
13
12
|
from novel_downloader.utils.constants import LOCALES_DIR
|
14
13
|
from novel_downloader.utils.state import state_mgr
|
15
14
|
|
16
|
-
_TRANSLATIONS:
|
15
|
+
_TRANSLATIONS: dict[str, dict[str, str]] = {}
|
17
16
|
|
18
17
|
for locale_path in LOCALES_DIR.glob("*.json"):
|
19
18
|
lang = locale_path.stem
|
20
19
|
try:
|
21
|
-
with open(locale_path,
|
20
|
+
with open(locale_path, encoding="utf-8") as f:
|
22
21
|
_TRANSLATIONS[lang] = json.load(f)
|
23
22
|
except Exception:
|
24
23
|
continue
|
novel_downloader/utils/logger.py
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
|
-
# -*- coding: utf-8 -*-
|
3
2
|
"""
|
4
3
|
novel_downloader.utils.logger
|
5
4
|
-----------------------------
|
@@ -12,13 +11,13 @@ import logging
|
|
12
11
|
from datetime import datetime
|
13
12
|
from logging.handlers import TimedRotatingFileHandler
|
14
13
|
from pathlib import Path
|
15
|
-
from typing import
|
14
|
+
from typing import Literal
|
16
15
|
|
17
16
|
from .constants import LOGGER_DIR, LOGGER_NAME
|
18
17
|
|
19
18
|
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"]
|
20
19
|
|
21
|
-
LOG_LEVELS:
|
20
|
+
LOG_LEVELS: dict[LogLevel, int] = {
|
22
21
|
"DEBUG": logging.DEBUG,
|
23
22
|
"INFO": logging.INFO,
|
24
23
|
"WARNING": logging.WARNING,
|
@@ -27,9 +26,9 @@ LOG_LEVELS: Dict[LogLevel, int] = {
|
|
27
26
|
|
28
27
|
|
29
28
|
def setup_logging(
|
30
|
-
log_filename_prefix:
|
31
|
-
log_level:
|
32
|
-
log_dir:
|
29
|
+
log_filename_prefix: str | None = None,
|
30
|
+
log_level: LogLevel | None = None,
|
31
|
+
log_dir: str | Path | None = None,
|
33
32
|
) -> logging.Logger:
|
34
33
|
"""
|
35
34
|
Create and configure a logger for both console and rotating file output.
|
@@ -1,5 +1,4 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
|
-
# -*- coding: utf-8 -*-
|
3
2
|
"""
|
4
3
|
novel_downloader.utils.model_loader
|
5
4
|
-----------------------------------
|
@@ -13,7 +12,7 @@ Currently supports:
|
|
13
12
|
from pathlib import Path
|
14
13
|
|
15
14
|
from huggingface_hub import hf_hub_download
|
16
|
-
from huggingface_hub.
|
15
|
+
from huggingface_hub.errors import LocalEntryNotFoundError
|
17
16
|
|
18
17
|
from novel_downloader.utils.constants import (
|
19
18
|
MODEL_CACHE_DIR,
|
@@ -38,12 +37,11 @@ def get_rec_chinese_char_model_dir(version: str = "v1.0") -> Path:
|
|
38
37
|
filename=fname,
|
39
38
|
revision=version,
|
40
39
|
local_dir=model_dir,
|
41
|
-
local_dir_use_symlinks=False,
|
42
40
|
)
|
43
|
-
except LocalEntryNotFoundError:
|
41
|
+
except LocalEntryNotFoundError as err:
|
44
42
|
raise RuntimeError(
|
45
43
|
f"[model] Missing model file '{fname}' and no internet connection."
|
46
|
-
)
|
44
|
+
) from err
|
47
45
|
return model_dir
|
48
46
|
|
49
47
|
|
@@ -62,11 +60,10 @@ def get_rec_char_vector_dir(version: str = "v1.0") -> Path:
|
|
62
60
|
filename=fname,
|
63
61
|
revision=version,
|
64
62
|
local_dir=vector_dir,
|
65
|
-
local_dir_use_symlinks=False,
|
66
63
|
)
|
67
|
-
except LocalEntryNotFoundError:
|
64
|
+
except LocalEntryNotFoundError as err:
|
68
65
|
raise RuntimeError(
|
69
66
|
f"[vector] Missing vector file '{fname}' and no internet connection."
|
70
|
-
)
|
67
|
+
) from err
|
71
68
|
|
72
69
|
return vector_dir
|
@@ -1,5 +1,4 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
|
-
# -*- coding: utf-8 -*-
|
3
2
|
"""
|
4
3
|
novel_downloader.utils.network
|
5
4
|
------------------------------
|
@@ -11,7 +10,7 @@ import logging
|
|
11
10
|
import random
|
12
11
|
import time
|
13
12
|
from pathlib import Path
|
14
|
-
from typing import
|
13
|
+
from typing import Literal
|
15
14
|
from urllib.parse import unquote, urlparse
|
16
15
|
|
17
16
|
import requests
|
@@ -30,9 +29,9 @@ def http_get_with_retry(
|
|
30
29
|
retries: int = 3,
|
31
30
|
timeout: int = 10,
|
32
31
|
backoff: float = 0.5,
|
33
|
-
headers:
|
32
|
+
headers: dict[str, str] | None = None,
|
34
33
|
stream: bool = False,
|
35
|
-
) ->
|
34
|
+
) -> requests.Response | None:
|
36
35
|
"""
|
37
36
|
Perform a GET request with retry support.
|
38
37
|
|
@@ -87,13 +86,13 @@ def image_url_to_filename(url: str) -> str:
|
|
87
86
|
|
88
87
|
def download_image_as_bytes(
|
89
88
|
url: str,
|
90
|
-
target_folder:
|
89
|
+
target_folder: str | Path | None = None,
|
91
90
|
*,
|
92
91
|
timeout: int = 10,
|
93
92
|
retries: int = 3,
|
94
93
|
backoff: float = 0.5,
|
95
94
|
on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
|
96
|
-
) ->
|
95
|
+
) -> bytes | None:
|
97
96
|
"""
|
98
97
|
Download an image from a given URL and return its content as bytes.
|
99
98
|
|
@@ -155,13 +154,13 @@ def download_image_as_bytes(
|
|
155
154
|
|
156
155
|
def download_font_file(
|
157
156
|
url: str,
|
158
|
-
target_folder:
|
157
|
+
target_folder: str | Path,
|
159
158
|
*,
|
160
159
|
timeout: int = 10,
|
161
160
|
retries: int = 3,
|
162
161
|
backoff: float = 0.5,
|
163
162
|
on_exist: Literal["overwrite", "skip", "rename"] = "skip",
|
164
|
-
) ->
|
163
|
+
) -> Path | None:
|
165
164
|
"""
|
166
165
|
Download a font file from a URL and save it locally with retry and overwrite control
|
167
166
|
|
@@ -226,13 +225,13 @@ def download_font_file(
|
|
226
225
|
|
227
226
|
def download_js_file(
|
228
227
|
url: str,
|
229
|
-
target_folder:
|
228
|
+
target_folder: str | Path,
|
230
229
|
*,
|
231
230
|
timeout: int = 10,
|
232
231
|
retries: int = 3,
|
233
232
|
backoff: float = 0.5,
|
234
233
|
on_exist: Literal["overwrite", "skip", "rename"] = "skip",
|
235
|
-
) ->
|
234
|
+
) -> Path | None:
|
236
235
|
"""
|
237
236
|
Download a JavaScript (.js) file from a URL and save it locally.
|
238
237
|
|
novel_downloader/utils/state.py
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
|
-
# -*- coding: utf-8 -*-
|
3
2
|
"""
|
4
3
|
novel_downloader.utils.state
|
5
4
|
----------------------------
|
@@ -11,7 +10,7 @@ Supported sections:
|
|
11
10
|
"""
|
12
11
|
import json
|
13
12
|
from pathlib import Path
|
14
|
-
from typing import Any
|
13
|
+
from typing import Any
|
15
14
|
|
16
15
|
from .constants import STATE_FILE
|
17
16
|
|
@@ -26,7 +25,7 @@ class StateManager:
|
|
26
25
|
self._path = path
|
27
26
|
self._data = self._load()
|
28
27
|
|
29
|
-
def _load(self) ->
|
28
|
+
def _load(self) -> dict[str, Any]:
|
30
29
|
"""
|
31
30
|
Load the configuration file into a Python dictionary.
|
32
31
|
|
@@ -50,7 +49,7 @@ class StateManager:
|
|
50
49
|
content = json.dumps(self._data, ensure_ascii=False, indent=2)
|
51
50
|
self._path.write_text(content, encoding="utf-8")
|
52
51
|
|
53
|
-
def _parse_cookie_string(self, cookie_str: str) ->
|
52
|
+
def _parse_cookie_string(self, cookie_str: str) -> dict[str, str]:
|
54
53
|
"""
|
55
54
|
Parse a Cookie header string into a dict.
|
56
55
|
|
@@ -58,7 +57,7 @@ class StateManager:
|
|
58
57
|
:return: mapping cookie names to values (missing '=' yields empty string)
|
59
58
|
:rtype: Dict[str, str]
|
60
59
|
"""
|
61
|
-
cookies:
|
60
|
+
cookies: dict[str, str] = {}
|
62
61
|
for item in cookie_str.split(";"):
|
63
62
|
item = item.strip()
|
64
63
|
if not item:
|
@@ -110,7 +109,7 @@ class StateManager:
|
|
110
109
|
site_data["manual_login"] = flag
|
111
110
|
self._save()
|
112
111
|
|
113
|
-
def get_cookies(self, site: str) ->
|
112
|
+
def get_cookies(self, site: str) -> dict[str, str]:
|
114
113
|
"""
|
115
114
|
Retrieve the persisted cookies for a specific site.
|
116
115
|
|
@@ -120,7 +119,7 @@ class StateManager:
|
|
120
119
|
cookies = self._data.get("sites", {}).get(site, {}).get("cookies", {})
|
121
120
|
return {str(k): str(v) for k, v in cookies.items()}
|
122
121
|
|
123
|
-
def set_cookies(self, site: str, cookies:
|
122
|
+
def set_cookies(self, site: str, cookies: str | dict[str, str]) -> None:
|
124
123
|
"""
|
125
124
|
Persist (overwrite) the cookies for a specific site.
|
126
125
|
|
@@ -1,5 +1,4 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
|
-
# -*- coding: utf-8 -*-
|
3
2
|
"""
|
4
3
|
novel_downloader.utils.text_utils.chapter_formatting
|
5
4
|
----------------------------------------------------
|
@@ -7,12 +6,8 @@ novel_downloader.utils.text_utils.chapter_formatting
|
|
7
6
|
Format chapter content with title, paragraph blocks, and optional author notes.
|
8
7
|
"""
|
9
8
|
|
10
|
-
from typing import List, Optional
|
11
9
|
|
12
|
-
|
13
|
-
def format_chapter(
|
14
|
-
title: str, paragraphs: str, author_say: Optional[str] = None
|
15
|
-
) -> str:
|
10
|
+
def format_chapter(title: str, paragraphs: str, author_say: str | None = None) -> str:
|
16
11
|
"""
|
17
12
|
Build a formatted chapter string with title, paragraphs, and optional author note.
|
18
13
|
|
@@ -22,7 +17,7 @@ def format_chapter(
|
|
22
17
|
:return: A single string where title, paragraphs, and author note
|
23
18
|
are separated by blank lines.
|
24
19
|
"""
|
25
|
-
parts:
|
20
|
+
parts: list[str] = [title.strip()]
|
26
21
|
|
27
22
|
# add each nonempty paragraph line
|
28
23
|
for ln in paragraphs.splitlines():
|
@@ -1,5 +1,4 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
|
-
# -*- coding: utf-8 -*-
|
3
2
|
"""
|
4
3
|
novel_downloader.utils.text_utils.font_mapping
|
5
4
|
----------------------------------------------
|
@@ -11,10 +10,8 @@ where characters are visually disguised via custom font glyphs but can be
|
|
11
10
|
recovered using a known mapping.
|
12
11
|
"""
|
13
12
|
|
14
|
-
from typing import Dict
|
15
13
|
|
16
|
-
|
17
|
-
def apply_font_mapping(text: str, font_map: Dict[str, str]) -> str:
|
14
|
+
def apply_font_mapping(text: str, font_map: dict[str, str]) -> str:
|
18
15
|
"""
|
19
16
|
Replace each character in `text` using `font_map`,
|
20
17
|
leaving unmapped characters unchanged.
|
@@ -1,5 +1,4 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
|
-
# -*- coding: utf-8 -*-
|
3
2
|
"""
|
4
3
|
novel_downloader.utils.time_utils.datetime_utils
|
5
4
|
------------------------------------------------
|
@@ -15,8 +14,7 @@ Includes:
|
|
15
14
|
|
16
15
|
import logging
|
17
16
|
import re
|
18
|
-
from datetime import datetime, timedelta, timezone
|
19
|
-
from typing import Optional, Tuple
|
17
|
+
from datetime import UTC, datetime, timedelta, timezone
|
20
18
|
|
21
19
|
logger = logging.getLogger(__name__)
|
22
20
|
|
@@ -61,8 +59,8 @@ def _parse_utc_offset(tz_str: str) -> timezone:
|
|
61
59
|
else:
|
62
60
|
try:
|
63
61
|
hours = int(offset_part)
|
64
|
-
except ValueError:
|
65
|
-
raise ValueError(f"Invalid UTC offset hours: '{offset_part}'")
|
62
|
+
except ValueError as err:
|
63
|
+
raise ValueError(f"Invalid UTC offset hours: '{offset_part}'") from err
|
66
64
|
return timezone(timedelta(hours=hours))
|
67
65
|
|
68
66
|
|
@@ -100,9 +98,9 @@ def _parse_datetime_flexible(dt_str: str) -> datetime:
|
|
100
98
|
def calculate_time_difference(
|
101
99
|
from_time_str: str,
|
102
100
|
tz_str: str = "UTC",
|
103
|
-
to_time_str:
|
101
|
+
to_time_str: str | None = None,
|
104
102
|
to_tz_str: str = "UTC",
|
105
|
-
) ->
|
103
|
+
) -> tuple[int, int, int, int]:
|
106
104
|
"""
|
107
105
|
Calculate the difference between two datetime values.
|
108
106
|
|
@@ -116,15 +114,15 @@ def calculate_time_difference(
|
|
116
114
|
# parse start time
|
117
115
|
tz_from = _parse_utc_offset(tz_str)
|
118
116
|
dt_from = _parse_datetime_flexible(from_time_str)
|
119
|
-
dt_from = dt_from.replace(tzinfo=tz_from).astimezone(
|
117
|
+
dt_from = dt_from.replace(tzinfo=tz_from).astimezone(UTC)
|
120
118
|
|
121
119
|
# parse end time or use now
|
122
120
|
if to_time_str:
|
123
121
|
tz_to = _parse_utc_offset(to_tz_str)
|
124
122
|
dt_to = _parse_datetime_flexible(to_time_str)
|
125
|
-
dt_to = dt_to.replace(tzinfo=tz_to).astimezone(
|
123
|
+
dt_to = dt_to.replace(tzinfo=tz_to).astimezone(UTC)
|
126
124
|
else:
|
127
|
-
dt_to = datetime.now(
|
125
|
+
dt_to = datetime.now(UTC)
|
128
126
|
|
129
127
|
delta = dt_to - dt_from
|
130
128
|
|
@@ -1,5 +1,4 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
|
-
# -*- coding: utf-8 -*-
|
3
2
|
"""
|
4
3
|
novel_downloader.utils.time_utils.sleep_utils
|
5
4
|
---------------------------------------------
|
@@ -14,7 +13,6 @@ Includes:
|
|
14
13
|
import logging
|
15
14
|
import random
|
16
15
|
import time
|
17
|
-
from typing import Optional
|
18
16
|
|
19
17
|
logger = logging.getLogger(__name__)
|
20
18
|
|
@@ -24,7 +22,7 @@ def sleep_with_random_delay(
|
|
24
22
|
add_spread: float = 0.0,
|
25
23
|
mul_spread: float = 1.0,
|
26
24
|
*,
|
27
|
-
max_sleep:
|
25
|
+
max_sleep: float | None = None,
|
28
26
|
) -> None:
|
29
27
|
"""
|
30
28
|
Sleep for a random duration by combining multiplicative and additive jitter.
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: novel-downloader
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.3.0
|
4
4
|
Summary: A command-line tool for downloading Chinese web novels from Qidian and similar platforms.
|
5
5
|
Author-email: Saudade Z <saudadez217@gmail.com>
|
6
6
|
License: MIT License
|
@@ -34,18 +34,16 @@ Classifier: License :: OSI Approved :: MIT License
|
|
34
34
|
Classifier: Natural Language :: Chinese (Simplified)
|
35
35
|
Classifier: Topic :: Utilities
|
36
36
|
Classifier: Programming Language :: Python :: 3
|
37
|
-
Classifier: Programming Language :: Python :: 3.8
|
38
|
-
Classifier: Programming Language :: Python :: 3.9
|
39
|
-
Classifier: Programming Language :: Python :: 3.10
|
40
|
-
Classifier: Programming Language :: Python :: 3.11
|
41
37
|
Classifier: Programming Language :: Python :: 3.12
|
42
|
-
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
39
|
+
Requires-Python: >=3.12
|
43
40
|
Description-Content-Type: text/markdown
|
44
41
|
License-File: LICENSE
|
45
42
|
Requires-Dist: requests
|
43
|
+
Requires-Dist: aiohttp
|
46
44
|
Requires-Dist: beautifulsoup4
|
47
45
|
Requires-Dist: DrissionPage
|
48
|
-
Requires-Dist:
|
46
|
+
Requires-Dist: opencv-python
|
49
47
|
Requires-Dist: lxml
|
50
48
|
Requires-Dist: platformdirs
|
51
49
|
Requires-Dist: click
|
@@ -64,10 +62,9 @@ Requires-Dist: scipy; extra == "font-recovery"
|
|
64
62
|
Requires-Dist: numpy; extra == "font-recovery"
|
65
63
|
Requires-Dist: tinycss2; extra == "font-recovery"
|
66
64
|
Requires-Dist: fonttools; extra == "font-recovery"
|
65
|
+
Requires-Dist: brotli; extra == "font-recovery"
|
67
66
|
Requires-Dist: pillow; extra == "font-recovery"
|
68
67
|
Requires-Dist: huggingface_hub; extra == "font-recovery"
|
69
|
-
Provides-Extra: async
|
70
|
-
Requires-Dist: aiohttp; extra == "async"
|
71
68
|
Dynamic: license-file
|
72
69
|
|
73
70
|
# novel-downloader
|
@@ -94,13 +91,10 @@ pip install novel-downloader
|
|
94
91
|
# 如需支持字体解密功能 (decode_font), 请使用:
|
95
92
|
# pip install novel-downloader[font-recovery]
|
96
93
|
|
97
|
-
#
|
98
|
-
# pip install novel-downloader[async]
|
99
|
-
|
100
|
-
# 初始化默认配置 (生成 settings.yaml)
|
94
|
+
# 初始化默认配置 (生成 settings.toml)
|
101
95
|
novel-cli settings init
|
102
96
|
|
103
|
-
# 编辑 ./settings.
|
97
|
+
# 编辑 ./settings.toml 完成 site/book_ids 等
|
104
98
|
# 可查看 docs/4-settings-schema.md
|
105
99
|
|
106
100
|
# 运行下载
|
@@ -117,7 +111,6 @@ cd novel-downloader
|
|
117
111
|
pip install .
|
118
112
|
# 或安装带可选功能:
|
119
113
|
# pip install .[font-recovery]
|
120
|
-
# pip install .[async]
|
121
114
|
```
|
122
115
|
|
123
116
|
更多使用方法, 查看 [使用示例](https://github.com/BowenZ217/novel-downloader/blob/main/docs/5-usage-examples.md)
|
@@ -127,7 +120,10 @@ pip install .
|
|
127
120
|
## 功能特性
|
128
121
|
|
129
122
|
- 爬取起点中文网的小说章节内容 (支持免费与已订阅章节)
|
130
|
-
-
|
123
|
+
- 断点续爬
|
124
|
+
- 自动整合所有章节并导出为
|
125
|
+
- TXT
|
126
|
+
- EPUB
|
131
127
|
- 支持活动广告过滤:
|
132
128
|
- [x] 章节标题
|
133
129
|
- [ ] 章节正文
|
@@ -141,8 +137,9 @@ pip install .
|
|
141
137
|
- [安装](https://github.com/BowenZ217/novel-downloader/blob/main/docs/1-installation.md)
|
142
138
|
- [环境准备](https://github.com/BowenZ217/novel-downloader/blob/main/docs/2-environment-setup.md)
|
143
139
|
- [配置](https://github.com/BowenZ217/novel-downloader/blob/main/docs/3-configuration.md)
|
144
|
-
- [settings.
|
140
|
+
- [settings.toml 配置说明](https://github.com/BowenZ217/novel-downloader/blob/main/docs/4-settings-schema.md)
|
145
141
|
- [使用示例](https://github.com/BowenZ217/novel-downloader/blob/main/docs/5-usage-examples.md)
|
142
|
+
- [支持站点列表](https://github.com/BowenZ217/novel-downloader/blob/main/docs/6-supported-sites.md)
|
146
143
|
- [文件保存](https://github.com/BowenZ217/novel-downloader/blob/main/docs/file-saving.md)
|
147
144
|
- [TODO](https://github.com/BowenZ217/novel-downloader/blob/main/docs/todo.md)
|
148
145
|
- [开发](https://github.com/BowenZ217/novel-downloader/blob/main/docs/develop.md)
|