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.
Files changed (137) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/download.py +14 -11
  3. novel_downloader/cli/export.py +19 -19
  4. novel_downloader/cli/ui.py +35 -8
  5. novel_downloader/config/adapter.py +216 -153
  6. novel_downloader/core/__init__.py +5 -6
  7. novel_downloader/core/archived/deqixs/fetcher.py +1 -28
  8. novel_downloader/core/downloaders/__init__.py +2 -0
  9. novel_downloader/core/downloaders/base.py +34 -85
  10. novel_downloader/core/downloaders/common.py +147 -171
  11. novel_downloader/core/downloaders/qianbi.py +30 -64
  12. novel_downloader/core/downloaders/qidian.py +157 -184
  13. novel_downloader/core/downloaders/qqbook.py +292 -0
  14. novel_downloader/core/downloaders/registry.py +2 -2
  15. novel_downloader/core/exporters/__init__.py +2 -0
  16. novel_downloader/core/exporters/base.py +37 -59
  17. novel_downloader/core/exporters/common.py +620 -0
  18. novel_downloader/core/exporters/linovelib.py +47 -0
  19. novel_downloader/core/exporters/qidian.py +41 -12
  20. novel_downloader/core/exporters/qqbook.py +28 -0
  21. novel_downloader/core/exporters/registry.py +2 -2
  22. novel_downloader/core/fetchers/__init__.py +4 -2
  23. novel_downloader/core/fetchers/aaatxt.py +2 -22
  24. novel_downloader/core/fetchers/b520.py +3 -23
  25. novel_downloader/core/fetchers/base.py +80 -105
  26. novel_downloader/core/fetchers/biquyuedu.py +2 -22
  27. novel_downloader/core/fetchers/dxmwx.py +10 -22
  28. novel_downloader/core/fetchers/esjzone.py +6 -29
  29. novel_downloader/core/fetchers/guidaye.py +2 -22
  30. novel_downloader/core/fetchers/hetushu.py +9 -29
  31. novel_downloader/core/fetchers/i25zw.py +2 -16
  32. novel_downloader/core/fetchers/ixdzs8.py +2 -16
  33. novel_downloader/core/fetchers/jpxs123.py +2 -16
  34. novel_downloader/core/fetchers/lewenn.py +2 -22
  35. novel_downloader/core/fetchers/linovelib.py +4 -20
  36. novel_downloader/core/fetchers/{eightnovel.py → n8novel.py} +12 -40
  37. novel_downloader/core/fetchers/piaotia.py +2 -16
  38. novel_downloader/core/fetchers/qbtr.py +2 -16
  39. novel_downloader/core/fetchers/qianbi.py +1 -20
  40. novel_downloader/core/fetchers/qidian.py +27 -68
  41. novel_downloader/core/fetchers/qqbook.py +177 -0
  42. novel_downloader/core/fetchers/quanben5.py +9 -29
  43. novel_downloader/core/fetchers/rate_limiter.py +22 -53
  44. novel_downloader/core/fetchers/sfacg.py +3 -16
  45. novel_downloader/core/fetchers/shencou.py +2 -16
  46. novel_downloader/core/fetchers/shuhaige.py +2 -22
  47. novel_downloader/core/fetchers/tongrenquan.py +2 -22
  48. novel_downloader/core/fetchers/ttkan.py +3 -14
  49. novel_downloader/core/fetchers/wanbengo.py +2 -22
  50. novel_downloader/core/fetchers/xiaoshuowu.py +2 -16
  51. novel_downloader/core/fetchers/xiguashuwu.py +4 -20
  52. novel_downloader/core/fetchers/xs63b.py +3 -15
  53. novel_downloader/core/fetchers/xshbook.py +2 -22
  54. novel_downloader/core/fetchers/yamibo.py +4 -28
  55. novel_downloader/core/fetchers/yibige.py +13 -26
  56. novel_downloader/core/interfaces/exporter.py +19 -7
  57. novel_downloader/core/interfaces/fetcher.py +23 -49
  58. novel_downloader/core/interfaces/parser.py +2 -2
  59. novel_downloader/core/parsers/__init__.py +4 -2
  60. novel_downloader/core/parsers/b520.py +2 -2
  61. novel_downloader/core/parsers/base.py +5 -39
  62. novel_downloader/core/parsers/esjzone.py +3 -3
  63. novel_downloader/core/parsers/{eightnovel.py → n8novel.py} +7 -7
  64. novel_downloader/core/parsers/qidian.py +717 -0
  65. novel_downloader/core/parsers/qqbook.py +709 -0
  66. novel_downloader/core/parsers/xiguashuwu.py +8 -15
  67. novel_downloader/core/searchers/__init__.py +2 -2
  68. novel_downloader/core/searchers/b520.py +1 -1
  69. novel_downloader/core/searchers/base.py +2 -2
  70. novel_downloader/core/searchers/{eightnovel.py → n8novel.py} +5 -5
  71. novel_downloader/locales/en.json +3 -3
  72. novel_downloader/locales/zh.json +3 -3
  73. novel_downloader/models/__init__.py +2 -0
  74. novel_downloader/models/book.py +1 -0
  75. novel_downloader/models/config.py +12 -0
  76. novel_downloader/resources/config/settings.toml +23 -5
  77. novel_downloader/resources/js_scripts/expr_to_json.js +14 -0
  78. novel_downloader/resources/js_scripts/qidian_decrypt_node.js +21 -16
  79. novel_downloader/resources/js_scripts/qq_decrypt_node.js +92 -0
  80. novel_downloader/utils/__init__.py +0 -2
  81. novel_downloader/utils/chapter_storage.py +2 -3
  82. novel_downloader/utils/constants.py +7 -3
  83. novel_downloader/utils/cookies.py +32 -17
  84. novel_downloader/utils/crypto_utils/__init__.py +0 -6
  85. novel_downloader/utils/crypto_utils/aes_util.py +1 -1
  86. novel_downloader/utils/crypto_utils/rc4.py +40 -50
  87. novel_downloader/utils/epub/__init__.py +2 -3
  88. novel_downloader/utils/epub/builder.py +6 -6
  89. novel_downloader/utils/epub/constants.py +1 -6
  90. novel_downloader/utils/epub/documents.py +7 -7
  91. novel_downloader/utils/epub/models.py +8 -8
  92. novel_downloader/utils/epub/utils.py +10 -10
  93. novel_downloader/utils/file_utils/io.py +48 -73
  94. novel_downloader/utils/file_utils/normalize.py +1 -7
  95. novel_downloader/utils/file_utils/sanitize.py +4 -11
  96. novel_downloader/utils/fontocr/__init__.py +13 -0
  97. novel_downloader/utils/{fontocr.py → fontocr/core.py} +72 -61
  98. novel_downloader/utils/fontocr/loader.py +52 -0
  99. novel_downloader/utils/logger.py +80 -56
  100. novel_downloader/utils/network.py +16 -40
  101. novel_downloader/utils/node_decryptor/__init__.py +13 -0
  102. novel_downloader/utils/node_decryptor/decryptor.py +342 -0
  103. novel_downloader/{core/parsers/qidian/utils → utils/node_decryptor}/decryptor_fetcher.py +5 -6
  104. novel_downloader/utils/text_utils/text_cleaner.py +39 -30
  105. novel_downloader/utils/text_utils/truncate_utils.py +3 -14
  106. novel_downloader/utils/time_utils/sleep_utils.py +53 -43
  107. novel_downloader/web/main.py +1 -1
  108. novel_downloader/web/pages/download.py +1 -1
  109. novel_downloader/web/pages/search.py +4 -4
  110. novel_downloader/web/services/task_manager.py +2 -0
  111. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/METADATA +5 -1
  112. novel_downloader-2.0.2.dist-info/RECORD +203 -0
  113. novel_downloader/core/exporters/common/__init__.py +0 -11
  114. novel_downloader/core/exporters/common/epub.py +0 -198
  115. novel_downloader/core/exporters/common/main_exporter.py +0 -64
  116. novel_downloader/core/exporters/common/txt.py +0 -146
  117. novel_downloader/core/exporters/epub_util.py +0 -215
  118. novel_downloader/core/exporters/linovelib/__init__.py +0 -11
  119. novel_downloader/core/exporters/linovelib/epub.py +0 -349
  120. novel_downloader/core/exporters/linovelib/main_exporter.py +0 -66
  121. novel_downloader/core/exporters/linovelib/txt.py +0 -139
  122. novel_downloader/core/exporters/txt_util.py +0 -67
  123. novel_downloader/core/parsers/qidian/__init__.py +0 -10
  124. novel_downloader/core/parsers/qidian/book_info_parser.py +0 -89
  125. novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -470
  126. novel_downloader/core/parsers/qidian/chapter_normal.py +0 -126
  127. novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
  128. novel_downloader/core/parsers/qidian/main_parser.py +0 -101
  129. novel_downloader/core/parsers/qidian/utils/__init__.py +0 -30
  130. novel_downloader/core/parsers/qidian/utils/fontmap_recover.py +0 -143
  131. novel_downloader/core/parsers/qidian/utils/helpers.py +0 -110
  132. novel_downloader/core/parsers/qidian/utils/node_decryptor.py +0 -175
  133. novel_downloader-2.0.0.dist-info/RECORD +0 -210
  134. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/WHEEL +0 -0
  135. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/entry_points.txt +0 -0
  136. {novel_downloader-2.0.0.dist-info → novel_downloader-2.0.2.dist-info}/licenses/LICENSE +0 -0
  137. {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
- @classmethod
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
- import io
296
-
297
- import numpy as np
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
- im = Image.open(io.BytesIO(resp.content)).convert("RGB")
306
- img_np = np.asarray(im)
302
+ img_np = ocr.load_image_array_from_bytes(resp.content)
307
303
 
308
- ocr = get_font_ocr(batch_size=1)
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 >= cls._CONF_THRESHOLD else None
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
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
17
17
 
18
18
 
19
19
  @register_searcher(
20
- site_keys=["biquge", "bqg", "b520"],
20
+ site_keys=["biquge", "b520"],
21
21
  )
22
22
  class BiqugeSearcher(BaseSearcher):
23
23
  site_name = "biquge"
@@ -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, SearcherProtocol):
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.eightnovel
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=["eightnovel", "8novel"],
20
+ site_keys=["n8novel", "8novel"],
21
21
  )
22
- class EightnovelSearcher(BaseSearcher):
23
- site_name = "8novel"
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/"
@@ -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",
@@ -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
  )
@@ -19,6 +19,7 @@ class ChapterInfoDict(TypedDict):
19
19
  title: str
20
20
  url: str
21
21
  chapterId: str
22
+ accessible: NotRequired[bool]
22
23
 
23
24
 
24
25
  class VolumeInfoDict(TypedDict):
@@ -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 = 1.0 # 最大请求速率 (requests per second), 如不设置则请设置一个较大的数或负数
8
- request_interval = 2.0 # 同一本书各章节请求间隔 (秒)
9
- raw_data_dir = "./raw_data" # 原始章节 JSON/DB 存放目录
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
- window = global;
2
- null_fun = function(){console.log(arguments);}
3
- window.outerHeight = 1000
4
- window.innerHeight = 100
5
- globalThis = window
6
- self = window
7
- window.location = {}
8
- location.protocol = "https:"
9
- location.hostname = "vipreader.qidian.com"
10
- setTimeout = null_fun
11
- setInterval = null_fun
12
- document = {createElement: null_fun, documentElement: {}, createEvent: null_fun, currentScript: {src: "https://qdfepccdn.qidian.com/www.qidian.com/fock/116594983210.js"}, domain: 'qidian.com'}
13
- navigator = {userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'}
14
- performance = {}
15
- performance.navigation = {type: 1}
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 script.js <input.json> <output.txt>"
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, cast
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
- parsed = json.loads(data)
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 = Path(user_config_path(APP_DIR_NAME, appauthor=False))
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
- filtered = "; ".join(pair for pair in cookies.split(";") if "=" in pair)
31
- parsed = SimpleCookie()
32
- parsed.load(filtered)
33
- return {k: v.value for k, v in parsed.items()}
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
- try:
42
- with state_file.open("r", encoding="utf-8") as f:
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
- for cookie in cookies:
49
- if cookie.get("name") != key:
50
- continue
51
- value = cookie.get("value")
52
- if isinstance(value, str):
53
- return value
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 {}
@@ -5,9 +5,3 @@ novel_downloader.utils.crypto_utils
5
5
 
6
6
  Generic cryptographic utilities
7
7
  """
8
-
9
- __all__ = [
10
- "rc4_crypt",
11
- ]
12
-
13
- from .rc4 import rc4_crypt
@@ -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