novel-downloader 2.0.0__py3-none-any.whl → 2.0.2__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/download.py +14 -11
- novel_downloader/cli/export.py +19 -19
- novel_downloader/cli/ui.py +35 -8
- novel_downloader/config/adapter.py +216 -153
- novel_downloader/core/__init__.py +5 -6
- novel_downloader/core/archived/deqixs/fetcher.py +1 -28
- novel_downloader/core/downloaders/__init__.py +2 -0
- novel_downloader/core/downloaders/base.py +34 -85
- novel_downloader/core/downloaders/common.py +147 -171
- novel_downloader/core/downloaders/qianbi.py +30 -64
- novel_downloader/core/downloaders/qidian.py +157 -184
- novel_downloader/core/downloaders/qqbook.py +292 -0
- novel_downloader/core/downloaders/registry.py +2 -2
- novel_downloader/core/exporters/__init__.py +2 -0
- novel_downloader/core/exporters/base.py +37 -59
- novel_downloader/core/exporters/common.py +620 -0
- novel_downloader/core/exporters/linovelib.py +47 -0
- novel_downloader/core/exporters/qidian.py +41 -12
- novel_downloader/core/exporters/qqbook.py +28 -0
- novel_downloader/core/exporters/registry.py +2 -2
- novel_downloader/core/fetchers/__init__.py +4 -2
- novel_downloader/core/fetchers/aaatxt.py +2 -22
- novel_downloader/core/fetchers/b520.py +3 -23
- novel_downloader/core/fetchers/base.py +80 -105
- novel_downloader/core/fetchers/biquyuedu.py +2 -22
- novel_downloader/core/fetchers/dxmwx.py +10 -22
- novel_downloader/core/fetchers/esjzone.py +6 -29
- novel_downloader/core/fetchers/guidaye.py +2 -22
- novel_downloader/core/fetchers/hetushu.py +9 -29
- novel_downloader/core/fetchers/i25zw.py +2 -16
- novel_downloader/core/fetchers/ixdzs8.py +2 -16
- novel_downloader/core/fetchers/jpxs123.py +2 -16
- novel_downloader/core/fetchers/lewenn.py +2 -22
- novel_downloader/core/fetchers/linovelib.py +4 -20
- novel_downloader/core/fetchers/{eightnovel.py → n8novel.py} +12 -40
- novel_downloader/core/fetchers/piaotia.py +2 -16
- novel_downloader/core/fetchers/qbtr.py +2 -16
- novel_downloader/core/fetchers/qianbi.py +1 -20
- novel_downloader/core/fetchers/qidian.py +27 -68
- novel_downloader/core/fetchers/qqbook.py +177 -0
- novel_downloader/core/fetchers/quanben5.py +9 -29
- novel_downloader/core/fetchers/rate_limiter.py +22 -53
- novel_downloader/core/fetchers/sfacg.py +3 -16
- novel_downloader/core/fetchers/shencou.py +2 -16
- novel_downloader/core/fetchers/shuhaige.py +2 -22
- novel_downloader/core/fetchers/tongrenquan.py +2 -22
- novel_downloader/core/fetchers/ttkan.py +3 -14
- novel_downloader/core/fetchers/wanbengo.py +2 -22
- novel_downloader/core/fetchers/xiaoshuowu.py +2 -16
- novel_downloader/core/fetchers/xiguashuwu.py +4 -20
- novel_downloader/core/fetchers/xs63b.py +3 -15
- novel_downloader/core/fetchers/xshbook.py +2 -22
- novel_downloader/core/fetchers/yamibo.py +4 -28
- novel_downloader/core/fetchers/yibige.py +13 -26
- novel_downloader/core/interfaces/exporter.py +19 -7
- novel_downloader/core/interfaces/fetcher.py +23 -49
- novel_downloader/core/interfaces/parser.py +2 -2
- novel_downloader/core/parsers/__init__.py +4 -2
- novel_downloader/core/parsers/b520.py +2 -2
- novel_downloader/core/parsers/base.py +5 -39
- novel_downloader/core/parsers/esjzone.py +3 -3
- novel_downloader/core/parsers/{eightnovel.py → n8novel.py} +7 -7
- novel_downloader/core/parsers/qidian.py +717 -0
- novel_downloader/core/parsers/qqbook.py +709 -0
- novel_downloader/core/parsers/xiguashuwu.py +8 -15
- novel_downloader/core/searchers/__init__.py +2 -2
- novel_downloader/core/searchers/b520.py +1 -1
- novel_downloader/core/searchers/base.py +2 -2
- novel_downloader/core/searchers/{eightnovel.py → n8novel.py} +5 -5
- novel_downloader/locales/en.json +3 -3
- novel_downloader/locales/zh.json +3 -3
- novel_downloader/models/__init__.py +2 -0
- novel_downloader/models/book.py +1 -0
- novel_downloader/models/config.py +12 -0
- novel_downloader/resources/config/settings.toml +23 -5
- novel_downloader/resources/js_scripts/expr_to_json.js +14 -0
- novel_downloader/resources/js_scripts/qidian_decrypt_node.js +21 -16
- novel_downloader/resources/js_scripts/qq_decrypt_node.js +92 -0
- novel_downloader/utils/__init__.py +0 -2
- novel_downloader/utils/chapter_storage.py +2 -3
- novel_downloader/utils/constants.py +7 -3
- novel_downloader/utils/cookies.py +32 -17
- novel_downloader/utils/crypto_utils/__init__.py +0 -6
- novel_downloader/utils/crypto_utils/aes_util.py +1 -1
- novel_downloader/utils/crypto_utils/rc4.py +40 -50
- novel_downloader/utils/epub/__init__.py +2 -3
- novel_downloader/utils/epub/builder.py +6 -6
- novel_downloader/utils/epub/constants.py +1 -6
- novel_downloader/utils/epub/documents.py +7 -7
- novel_downloader/utils/epub/models.py +8 -8
- novel_downloader/utils/epub/utils.py +10 -10
- novel_downloader/utils/file_utils/io.py +48 -73
- novel_downloader/utils/file_utils/normalize.py +1 -7
- novel_downloader/utils/file_utils/sanitize.py +4 -11
- novel_downloader/utils/fontocr/__init__.py +13 -0
- novel_downloader/utils/{fontocr.py → fontocr/core.py} +72 -61
- novel_downloader/utils/fontocr/loader.py +52 -0
- novel_downloader/utils/logger.py +80 -56
- novel_downloader/utils/network.py +16 -40
- novel_downloader/utils/node_decryptor/__init__.py +13 -0
- novel_downloader/utils/node_decryptor/decryptor.py +342 -0
- novel_downloader/{core/parsers/qidian/utils → utils/node_decryptor}/decryptor_fetcher.py +5 -6
- 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/sleep_utils.py +53 -43
- novel_downloader/web/main.py +1 -1
- novel_downloader/web/pages/download.py +1 -1
- novel_downloader/web/pages/search.py +4 -4
- novel_downloader/web/services/task_manager.py +2 -0
- {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/METADATA +5 -1
- novel_downloader-2.0.2.dist-info/RECORD +203 -0
- novel_downloader/core/exporters/common/__init__.py +0 -11
- novel_downloader/core/exporters/common/epub.py +0 -198
- novel_downloader/core/exporters/common/main_exporter.py +0 -64
- novel_downloader/core/exporters/common/txt.py +0 -146
- novel_downloader/core/exporters/epub_util.py +0 -215
- novel_downloader/core/exporters/linovelib/__init__.py +0 -11
- novel_downloader/core/exporters/linovelib/epub.py +0 -349
- novel_downloader/core/exporters/linovelib/main_exporter.py +0 -66
- novel_downloader/core/exporters/linovelib/txt.py +0 -139
- novel_downloader/core/exporters/txt_util.py +0 -67
- novel_downloader/core/parsers/qidian/__init__.py +0 -10
- novel_downloader/core/parsers/qidian/book_info_parser.py +0 -89
- novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -470
- novel_downloader/core/parsers/qidian/chapter_normal.py +0 -126
- novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
- novel_downloader/core/parsers/qidian/main_parser.py +0 -101
- novel_downloader/core/parsers/qidian/utils/__init__.py +0 -30
- novel_downloader/core/parsers/qidian/utils/fontmap_recover.py +0 -143
- novel_downloader/core/parsers/qidian/utils/helpers.py +0 -110
- novel_downloader/core/parsers/qidian/utils/node_decryptor.py +0 -175
- novel_downloader-2.0.0.dist-info/RECORD +0 -210
- {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/WHEEL +0 -0
- {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/entry_points.txt +0 -0
- {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/top_level.txt +0 -0
@@ -29,6 +29,7 @@ from novel_downloader.utils.constants import (
|
|
29
29
|
XIGUASHUWU_FONT_MAP_PATH,
|
30
30
|
)
|
31
31
|
from novel_downloader.utils.crypto_utils.aes_util import aes_cbc_decrypt
|
32
|
+
from novel_downloader.utils.fontocr import get_font_ocr
|
32
33
|
|
33
34
|
logger = logging.getLogger(__name__)
|
34
35
|
|
@@ -283,8 +284,7 @@ class XiguashuwuParser(BaseParser):
|
|
283
284
|
return char
|
284
285
|
return f'<img src="{url}" />'
|
285
286
|
|
286
|
-
|
287
|
-
def _recognize_glyph_from_url(cls, url: str) -> str | None:
|
287
|
+
def _recognize_glyph_from_url(self, url: str) -> str | None:
|
288
288
|
"""
|
289
289
|
Download the glyph image at `url` and run the font OCR on it.
|
290
290
|
|
@@ -292,26 +292,19 @@ class XiguashuwuParser(BaseParser):
|
|
292
292
|
:return: The recognized character (top-1) if OCR succeeds, otherwise None.
|
293
293
|
"""
|
294
294
|
try:
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
from PIL import Image
|
299
|
-
|
300
|
-
from novel_downloader.utils.fontocr import get_font_ocr
|
295
|
+
ocr = get_font_ocr(self._fontocr_cfg)
|
296
|
+
if not ocr:
|
297
|
+
return None
|
301
298
|
|
302
299
|
resp = requests.get(url, headers=DEFAULT_USER_HEADERS, timeout=15)
|
303
300
|
resp.raise_for_status()
|
304
301
|
|
305
|
-
|
306
|
-
img_np = np.asarray(im)
|
302
|
+
img_np = ocr.load_image_array_from_bytes(resp.content)
|
307
303
|
|
308
|
-
|
309
|
-
char, score = ocr.predict([img_np], top_k=1)[0][0]
|
304
|
+
char, score = ocr.predict([img_np])[0]
|
310
305
|
|
311
|
-
return char if score >=
|
306
|
+
return char if score >= self._CONF_THRESHOLD else None
|
312
307
|
|
313
|
-
except ImportError:
|
314
|
-
logger.warning("[Parser] FontOCR not available, font decoding will skip")
|
315
308
|
except Exception as e:
|
316
309
|
logger.warning("[Parser] Failed to ocr glyph image %s: %s", url, e)
|
317
310
|
return None
|
@@ -11,12 +11,12 @@ __all__ = [
|
|
11
11
|
"AaatxtSearcher",
|
12
12
|
"BiqugeSearcher",
|
13
13
|
"DxmwxSearcher",
|
14
|
-
"EightnovelSearcher",
|
15
14
|
"EsjzoneSearcher",
|
16
15
|
"HetushuSearcher",
|
17
16
|
"I25zwSearcher",
|
18
17
|
"Ixdzs8Searcher",
|
19
18
|
"Jpxs123Searcher",
|
19
|
+
"N8novelSearcher",
|
20
20
|
"PiaotiaSearcher",
|
21
21
|
"QbtrSearcher",
|
22
22
|
"QianbiSearcher",
|
@@ -32,12 +32,12 @@ __all__ = [
|
|
32
32
|
from .aaatxt import AaatxtSearcher
|
33
33
|
from .b520 import BiqugeSearcher
|
34
34
|
from .dxmwx import DxmwxSearcher
|
35
|
-
from .eightnovel import EightnovelSearcher
|
36
35
|
from .esjzone import EsjzoneSearcher
|
37
36
|
from .hetushu import HetushuSearcher
|
38
37
|
from .i25zw import I25zwSearcher
|
39
38
|
from .ixdzs8 import Ixdzs8Searcher
|
40
39
|
from .jpxs123 import Jpxs123Searcher
|
40
|
+
from .n8novel import N8novelSearcher
|
41
41
|
from .piaotia import PiaotiaSearcher
|
42
42
|
from .qbtr import QbtrSearcher
|
43
43
|
from .qianbi import QianbiSearcher
|
@@ -12,13 +12,13 @@ from urllib.parse import quote_plus, urljoin
|
|
12
12
|
|
13
13
|
import aiohttp
|
14
14
|
|
15
|
-
from novel_downloader.core.interfaces import SearcherProtocol
|
16
15
|
from novel_downloader.models import SearchResult
|
17
16
|
from novel_downloader.utils.constants import DEFAULT_USER_HEADERS
|
18
17
|
|
19
18
|
|
20
|
-
class BaseSearcher(abc.ABC
|
19
|
+
class BaseSearcher(abc.ABC):
|
21
20
|
site_name: str
|
21
|
+
priority: int = 1000
|
22
22
|
BASE_URL: str = ""
|
23
23
|
_session: ClassVar[aiohttp.ClientSession | None] = None
|
24
24
|
|
@@ -1,7 +1,7 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
novel_downloader.core.searchers.
|
4
|
-
|
3
|
+
novel_downloader.core.searchers.n8novel
|
4
|
+
---------------------------------------
|
5
5
|
|
6
6
|
"""
|
7
7
|
|
@@ -17,10 +17,10 @@ logger = logging.getLogger(__name__)
|
|
17
17
|
|
18
18
|
|
19
19
|
@register_searcher(
|
20
|
-
site_keys=["
|
20
|
+
site_keys=["n8novel", "8novel"],
|
21
21
|
)
|
22
|
-
class
|
23
|
-
site_name = "
|
22
|
+
class N8novelSearcher(BaseSearcher):
|
23
|
+
site_name = "n8novel"
|
24
24
|
priority = 20
|
25
25
|
BASE_URL = "https://www.8novel.com"
|
26
26
|
SEARCH_URL = "https://www.8novel.com/search/"
|
novel_downloader/locales/en.json
CHANGED
@@ -39,9 +39,9 @@
|
|
39
39
|
"login_description": "Description",
|
40
40
|
"login_hint": "Hint",
|
41
41
|
"login_use_config": "Using value from config.",
|
42
|
-
"login_enter_password": "Enter password
|
43
|
-
"login_enter_cookie": "Enter cookies
|
44
|
-
"login_enter_value": "Enter value
|
42
|
+
"login_enter_password": "Enter password",
|
43
|
+
"login_enter_cookie": "Enter cookies",
|
44
|
+
"login_enter_value": "Enter value",
|
45
45
|
"login_required_field": "This field is required. Please enter a value.",
|
46
46
|
|
47
47
|
"clean_logs": "Clean log directory",
|
novel_downloader/locales/zh.json
CHANGED
@@ -39,9 +39,9 @@
|
|
39
39
|
"login_description": "说明",
|
40
40
|
"login_hint": "提示",
|
41
41
|
"login_use_config": "使用配置中的默认值",
|
42
|
-
"login_enter_password": "
|
43
|
-
"login_enter_cookie": "请输入 Cookie
|
44
|
-
"login_enter_value": "
|
42
|
+
"login_enter_password": "请输入密码",
|
43
|
+
"login_enter_cookie": "请输入 Cookie",
|
44
|
+
"login_enter_value": "请输入值",
|
45
45
|
"login_required_field": "该字段是必填项, 请重新输入",
|
46
46
|
|
47
47
|
"clean_failed": "删除失败: {path}",
|
@@ -11,6 +11,7 @@ __all__ = [
|
|
11
11
|
"DownloaderConfig",
|
12
12
|
"ParserConfig",
|
13
13
|
"FetcherConfig",
|
14
|
+
"FontOCRConfig",
|
14
15
|
"ExporterConfig",
|
15
16
|
"TextCleanerConfig",
|
16
17
|
"BookInfoDict",
|
@@ -32,6 +33,7 @@ from .config import (
|
|
32
33
|
DownloaderConfig,
|
33
34
|
ExporterConfig,
|
34
35
|
FetcherConfig,
|
36
|
+
FontOCRConfig,
|
35
37
|
ParserConfig,
|
36
38
|
TextCleanerConfig,
|
37
39
|
)
|
novel_downloader/models/book.py
CHANGED
@@ -39,6 +39,17 @@ class DownloaderConfig:
|
|
39
39
|
storage_batch_size: int = 1
|
40
40
|
|
41
41
|
|
42
|
+
@dataclass
|
43
|
+
class FontOCRConfig:
|
44
|
+
model_name: str | None = None
|
45
|
+
model_dir: str | None = None
|
46
|
+
input_shape: tuple[int, int, int] | None = None
|
47
|
+
device: str | None = None
|
48
|
+
precision: str = "fp32"
|
49
|
+
cpu_threads: int = 10
|
50
|
+
enable_hpi: bool = False
|
51
|
+
|
52
|
+
|
42
53
|
@dataclass
|
43
54
|
class ParserConfig:
|
44
55
|
cache_dir: str = "./novel_cache"
|
@@ -46,6 +57,7 @@ class ParserConfig:
|
|
46
57
|
decode_font: bool = False
|
47
58
|
batch_size: int = 32
|
48
59
|
save_font_debug: bool = False
|
60
|
+
fontocr_cfg: FontOCRConfig = field(default_factory=FontOCRConfig)
|
49
61
|
|
50
62
|
|
51
63
|
@dataclass
|
@@ -2,13 +2,13 @@
|
|
2
2
|
[general]
|
3
3
|
retry_times = 3 # 请求失败重试次数
|
4
4
|
backoff_factor = 2.0
|
5
|
-
timeout = 30.0 #
|
5
|
+
timeout = 30.0 # 请求加载超时时间 (秒)
|
6
6
|
max_connections = 10 # 并发连接的最大数
|
7
|
-
max_rps =
|
8
|
-
request_interval =
|
9
|
-
raw_data_dir = "./raw_data" #
|
7
|
+
max_rps = 1000.0 # 最大请求速率 (requests per second), 如不设置则请设置一个较大的数或负数
|
8
|
+
request_interval = 1.0 # 同一本书各章节请求间隔 (秒)
|
9
|
+
raw_data_dir = "./raw_data" # 书籍原始 JSON/DB/图片 数据存放目录
|
10
10
|
output_dir = "./downloads" # 最终输出文件存放目录
|
11
|
-
cache_dir = "./novel_cache" # 本地缓存目录
|
11
|
+
cache_dir = "./novel_cache" # 本地缓存目录
|
12
12
|
workers = 2 # 工作协程数
|
13
13
|
skip_existing = true # 是否跳过已存在章节
|
14
14
|
storage_batch_size = 1 # SQLite 批量提交的章节数量
|
@@ -23,6 +23,17 @@ decode_font = false # 是否尝试本地解码混淆字体
|
|
23
23
|
batch_size = 32
|
24
24
|
save_font_debug = false # 是否保存字体解码调试数据
|
25
25
|
|
26
|
+
# 以下为 PaddleOCR 文本识别的可选参数
|
27
|
+
# 默认情况下, 请保持注释, 程序会使用 PaddleOCR 内置的默认值
|
28
|
+
# 如果需要修改配置, 请取消注释并填写参数值 (参考官网文档)
|
29
|
+
# https://www.paddleocr.ai/main/version3.x/module_usage/text_recognition.html
|
30
|
+
|
31
|
+
model_name = "PP-OCRv5_mobile_rec"
|
32
|
+
# model_dir = "/path/to/inference"
|
33
|
+
# input_shape = [3, 48, 320] # [C, H, W], 按所选模型文档填写, 不确定请注释
|
34
|
+
# device = "gpu" # "cpu" / "gpu"
|
35
|
+
# cpu_threads = 10
|
36
|
+
|
26
37
|
# 各站点的特定配置
|
27
38
|
[sites.qidian] # 起点中文网
|
28
39
|
book_ids = [
|
@@ -32,6 +43,13 @@ book_ids = [
|
|
32
43
|
login_required = true # 是否需要登录才能访问
|
33
44
|
use_truncation = true # 是否基于章节长度截断以避免重复内容
|
34
45
|
|
46
|
+
[sites.qqbook] # QQ 阅读
|
47
|
+
book_ids = [
|
48
|
+
"0000000000",
|
49
|
+
"0000000000"
|
50
|
+
]
|
51
|
+
login_required = true
|
52
|
+
|
35
53
|
[sites.esjzone] # ESJ Zone
|
36
54
|
book_ids = [
|
37
55
|
"0000000000",
|
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
|
3
|
+
let code = "";
|
4
|
+
process.stdin.on("data", chunk => code += chunk);
|
5
|
+
process.stdin.on("end", () => {
|
6
|
+
try {
|
7
|
+
// Make sure object literals parse correctly
|
8
|
+
const result = eval("(" + code + ")");
|
9
|
+
console.log(JSON.stringify(result));
|
10
|
+
} catch (err) {
|
11
|
+
console.error("Error:", err);
|
12
|
+
process.exit(1);
|
13
|
+
}
|
14
|
+
});
|
@@ -1,18 +1,22 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
1
|
+
#!/usr/bin/env node
|
2
|
+
|
3
|
+
// ---- GLOBAL ENVIRONMENT SHIMS ----
|
4
|
+
const shimEnv = {
|
5
|
+
outerHeight: 1000,
|
6
|
+
innerHeight: 100,
|
7
|
+
location: {
|
8
|
+
protocol: "https:",
|
9
|
+
hostname: "vipreader.qidian.com",
|
10
|
+
},
|
11
|
+
};
|
12
|
+
|
13
|
+
global.window = global;
|
14
|
+
globalThis.window = global;
|
15
|
+
globalThis.self = global;
|
16
|
+
|
17
|
+
for (const [key, value] of Object.entries(shimEnv)) {
|
18
|
+
globalThis[key] = value;
|
19
|
+
}
|
16
20
|
|
17
21
|
// ---- DECRYPT FUNCTION ----
|
18
22
|
const Fock = require('./4819793b.qeooxh.js');
|
@@ -41,7 +45,7 @@ const fs = require('fs');
|
|
41
45
|
const [inputPath, outputPath] = process.argv.slice(2);
|
42
46
|
if (!inputPath || !outputPath) {
|
43
47
|
console.error(
|
44
|
-
"Usage: node
|
48
|
+
"Usage: node decrypt_qd.js <input.json> <output.txt>"
|
45
49
|
);
|
46
50
|
process.exit(1);
|
47
51
|
}
|
@@ -75,6 +79,7 @@ const fs = require('fs');
|
|
75
79
|
]);
|
76
80
|
|
77
81
|
fs.writeFileSync(outputPath, result, "utf-8");
|
82
|
+
process.exit(0);
|
78
83
|
} catch (err) {
|
79
84
|
console.error("Failed to decrypt:", err);
|
80
85
|
process.exit(1);
|
@@ -0,0 +1,92 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
|
3
|
+
// ---- GLOBAL ENVIRONMENT SHIMS ----
|
4
|
+
const _setInterval = global.setInterval;
|
5
|
+
|
6
|
+
const shimEnv = {
|
7
|
+
location: {
|
8
|
+
protocol: "https:",
|
9
|
+
hostname: "book.qq.com",
|
10
|
+
},
|
11
|
+
setInterval: (fn, t) => typeof fn === "function" ? _setInterval(fn, t) : undefined,
|
12
|
+
document: {
|
13
|
+
createElement: (tag) => {
|
14
|
+
if (tag === "iframe") {
|
15
|
+
const win = {};
|
16
|
+
win.window = win;
|
17
|
+
win.eval = (code) => eval(code);
|
18
|
+
return {
|
19
|
+
style: {},
|
20
|
+
contentWindow: win,
|
21
|
+
};
|
22
|
+
}
|
23
|
+
return { style: {}, appendChild: () => {} };
|
24
|
+
},
|
25
|
+
body: { appendChild: () => {} },
|
26
|
+
},
|
27
|
+
};
|
28
|
+
|
29
|
+
global.window = global;
|
30
|
+
globalThis.window = global;
|
31
|
+
globalThis.self = global;
|
32
|
+
|
33
|
+
for (const [key, value] of Object.entries(shimEnv)) {
|
34
|
+
globalThis[key] = value;
|
35
|
+
}
|
36
|
+
|
37
|
+
// ---- DECRYPT FUNCTION ----
|
38
|
+
const Fock = require('./cefc2a5d.pz1phw.js');
|
39
|
+
|
40
|
+
async function decrypt(enContent, cuChapterId, fkp, fuid) {
|
41
|
+
Fock.setupUserKey(fuid);
|
42
|
+
eval(atob(fkp));
|
43
|
+
isFockInit = true;
|
44
|
+
|
45
|
+
return new Promise((resolve, reject) => {
|
46
|
+
Fock.unlock(enContent, cuChapterId, (code, decrypted) => {
|
47
|
+
if (code === 0) {
|
48
|
+
resolve(decrypted);
|
49
|
+
} else {
|
50
|
+
reject(new Error(`Fock.unlock failed, code=${code}`));
|
51
|
+
}
|
52
|
+
});
|
53
|
+
});
|
54
|
+
}
|
55
|
+
|
56
|
+
// ---- MAIN ----
|
57
|
+
const fs = require('fs');
|
58
|
+
|
59
|
+
(async () => {
|
60
|
+
const [inputPath, outputPath] = process.argv.slice(2);
|
61
|
+
|
62
|
+
if (!inputPath || !outputPath) {
|
63
|
+
console.error("Usage: node decrypt_qq.js <input.json> <output.txt>");
|
64
|
+
process.exit(1);
|
65
|
+
}
|
66
|
+
|
67
|
+
try {
|
68
|
+
const inputData = fs.readFileSync(inputPath, "utf-8");
|
69
|
+
const [raw_enContent, raw_cuChapterId, raw_fkp, raw_fuid] = JSON.parse(inputData);
|
70
|
+
|
71
|
+
const decryptPromise = decrypt(
|
72
|
+
String(raw_enContent),
|
73
|
+
String(raw_cuChapterId),
|
74
|
+
String(raw_fkp),
|
75
|
+
String(raw_fuid)
|
76
|
+
);
|
77
|
+
|
78
|
+
const timeoutMs = 5000;
|
79
|
+
const timerPromise = new Promise((_, reject) => {
|
80
|
+
setTimeout(() => reject(new Error(`decrypt timeout after ${timeoutMs}ms`)), timeoutMs);
|
81
|
+
});
|
82
|
+
|
83
|
+
const result = await Promise.race([decryptPromise, timerPromise]);
|
84
|
+
console.log("result", result);
|
85
|
+
|
86
|
+
fs.writeFileSync(outputPath, result, "utf-8");
|
87
|
+
process.exit(0);
|
88
|
+
} catch (err) {
|
89
|
+
console.error("Failed to decrypt:", err);
|
90
|
+
process.exit(1);
|
91
|
+
}
|
92
|
+
})();
|
@@ -11,7 +11,6 @@ __all__ = [
|
|
11
11
|
"TextCleaner",
|
12
12
|
"parse_cookies",
|
13
13
|
"get_cookie_value",
|
14
|
-
"rc4_crypt",
|
15
14
|
"sanitize_filename",
|
16
15
|
"write_file",
|
17
16
|
"download",
|
@@ -29,7 +28,6 @@ from .cookies import (
|
|
29
28
|
get_cookie_value,
|
30
29
|
parse_cookies,
|
31
30
|
)
|
32
|
-
from .crypto_utils import rc4_crypt
|
33
31
|
from .file_utils import (
|
34
32
|
sanitize_filename,
|
35
33
|
write_file,
|
@@ -13,7 +13,7 @@ import json
|
|
13
13
|
import sqlite3
|
14
14
|
import types
|
15
15
|
from pathlib import Path
|
16
|
-
from typing import Any, Self
|
16
|
+
from typing import Any, Self
|
17
17
|
|
18
18
|
from novel_downloader.models import ChapterDict
|
19
19
|
|
@@ -313,8 +313,7 @@ class ChapterStorage:
|
|
313
313
|
@staticmethod
|
314
314
|
def _load_dict(data: str) -> dict[str, Any]:
|
315
315
|
try:
|
316
|
-
|
317
|
-
return cast(dict[str, Any], parsed)
|
316
|
+
return json.loads(data) or {}
|
318
317
|
except Exception:
|
319
318
|
return {}
|
320
319
|
|
@@ -16,14 +16,12 @@ from platformdirs import user_config_path
|
|
16
16
|
# -----------------------------------------------------------------------------
|
17
17
|
PACKAGE_NAME = "novel_downloader" # Python package name
|
18
18
|
APP_NAME = "NovelDownloader" # Display name
|
19
|
-
APP_DIR_NAME = PACKAGE_NAME # Directory name for platformdirs
|
20
|
-
LOGGER_NAME = PACKAGE_NAME # Root logger name
|
21
19
|
|
22
20
|
# -----------------------------------------------------------------------------
|
23
21
|
# Base directories
|
24
22
|
# -----------------------------------------------------------------------------
|
25
23
|
# Base config directory (e.g. ~/AppData/Local/novel_downloader/)
|
26
|
-
BASE_CONFIG_DIR =
|
24
|
+
BASE_CONFIG_DIR = user_config_path(PACKAGE_NAME, appauthor=False)
|
27
25
|
WORK_DIR = Path.cwd()
|
28
26
|
PACKAGE_ROOT: Path = Path(__file__).parent.parent
|
29
27
|
LOCALES_DIR: Path = PACKAGE_ROOT / "locales"
|
@@ -92,6 +90,12 @@ XIGUASHUWU_FONT_MAP_PATH = files("novel_downloader.resources.json").joinpath(
|
|
92
90
|
)
|
93
91
|
|
94
92
|
# JavaScript
|
93
|
+
EXPR_TO_JSON_SCRIPT_PATH = files("novel_downloader.resources.js_scripts").joinpath(
|
94
|
+
"expr_to_json.js"
|
95
|
+
)
|
95
96
|
QD_DECRYPT_SCRIPT_PATH = files("novel_downloader.resources.js_scripts").joinpath(
|
96
97
|
"qidian_decrypt_node.js"
|
97
98
|
)
|
99
|
+
QQ_DECRYPT_SCRIPT_PATH = files("novel_downloader.resources.js_scripts").joinpath(
|
100
|
+
"qq_decrypt_node.js"
|
101
|
+
)
|
@@ -8,10 +8,11 @@ Utility for normalizing cookie input from user configuration.
|
|
8
8
|
|
9
9
|
__all__ = ["parse_cookies", "get_cookie_value"]
|
10
10
|
|
11
|
+
import functools
|
11
12
|
import json
|
12
13
|
from collections.abc import Mapping
|
13
|
-
from http.cookies import SimpleCookie
|
14
14
|
from pathlib import Path
|
15
|
+
from typing import Any
|
15
16
|
|
16
17
|
|
17
18
|
def parse_cookies(cookies: str | Mapping[str, str]) -> dict[str, str]:
|
@@ -27,10 +28,16 @@ def parse_cookies(cookies: str | Mapping[str, str]) -> dict[str, str]:
|
|
27
28
|
:raises TypeError: If the input is neither string nor dict-like
|
28
29
|
"""
|
29
30
|
if isinstance(cookies, str):
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
31
|
+
result: dict[str, str] = {}
|
32
|
+
for part in cookies.split(";"):
|
33
|
+
if "=" not in part:
|
34
|
+
continue
|
35
|
+
key, value = part.split("=", 1)
|
36
|
+
key, value = key.strip(), value.strip()
|
37
|
+
if not key:
|
38
|
+
continue
|
39
|
+
result[key] = value
|
40
|
+
return result
|
34
41
|
elif isinstance(cookies, Mapping):
|
35
42
|
return {str(k).strip(): str(v).strip() for k, v in cookies.items()}
|
36
43
|
raise TypeError("Unsupported cookie format: must be str or dict-like")
|
@@ -38,17 +45,25 @@ def parse_cookies(cookies: str | Mapping[str, str]) -> dict[str, str]:
|
|
38
45
|
|
39
46
|
def get_cookie_value(state_files: list[Path], key: str) -> str:
|
40
47
|
for state_file in state_files:
|
41
|
-
|
42
|
-
|
43
|
-
data = json.load(f)
|
44
|
-
except Exception:
|
45
|
-
continue
|
46
|
-
|
48
|
+
mtime = state_file.stat().st_mtime
|
49
|
+
data = load_state_file(state_file, mtime)
|
47
50
|
cookies = data.get("cookies", [])
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
51
|
+
value = next(
|
52
|
+
(
|
53
|
+
c.get("value")
|
54
|
+
for c in cookies
|
55
|
+
if c.get("name") == key and isinstance(c.get("value"), str)
|
56
|
+
),
|
57
|
+
None,
|
58
|
+
)
|
59
|
+
if isinstance(value, str):
|
60
|
+
return value
|
54
61
|
return ""
|
62
|
+
|
63
|
+
|
64
|
+
@functools.cache
|
65
|
+
def load_state_file(state_file: Path, mtime: float = 0.0) -> dict[str, Any]:
|
66
|
+
try:
|
67
|
+
return json.loads(state_file.read_text(encoding="utf-8")) or {}
|
68
|
+
except (OSError, json.JSONDecodeError):
|
69
|
+
return {}
|
@@ -65,7 +65,7 @@ try:
|
|
65
65
|
except ImportError:
|
66
66
|
print(
|
67
67
|
"[crypto_utils] Falling back to pure-Python AES_CBC.\n"
|
68
|
-
"Tip: pip install pycryptodome for ~800x faster speed."
|
68
|
+
"Tip: `pip install pycryptodome` for ~800x faster speed."
|
69
69
|
)
|
70
70
|
from novel_downloader.utils.crypto_utils.aes_v2 import AES_CBC
|
71
71
|
|