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
@@ -1,28 +1,23 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.utils.fontocr
4
- ------------------------------
3
+ novel_downloader.utils.fontocr.core
4
+ -----------------------------------
5
5
 
6
6
  This class provides utility methods for optical character recognition (OCR),
7
7
  primarily used for decrypting custom font encryption.
8
8
  """
9
9
 
10
- __all__ = [
11
- "FontOCR",
12
- "get_font_ocr",
13
- ]
14
- __version__ = "4.0"
15
-
10
+ import io
16
11
  import logging
17
- from collections.abc import Generator
18
- from typing import Any, TypeVar
12
+ from pathlib import Path
13
+ from typing import Any
19
14
 
20
15
  import numpy as np
21
- from paddleocr import TextRecognition # takes 5 ~ 12 sec to init
16
+ from fontTools.ttLib import TTFont
17
+ from paddleocr import TextRecognition
22
18
  from PIL import Image, ImageDraw, ImageFont
23
19
  from PIL.Image import Transpose
24
20
 
25
- T = TypeVar("T")
26
21
  logger = logging.getLogger(__name__)
27
22
 
28
23
 
@@ -39,45 +34,45 @@ class FontOCR:
39
34
  device: str | None = None,
40
35
  precision: str = "fp32",
41
36
  cpu_threads: int = 10,
42
- batch_size: int = 32,
43
- threshold: float = 0.0,
37
+ enable_hpi: bool = False,
44
38
  **kwargs: Any,
45
39
  ) -> None:
46
40
  """
47
41
  Initialize a FontOCR instance.
48
42
 
49
- :param batch_size: batch size for OCR inference (minimum 1)
50
- :param ocr_weight: weight factor for OCR-based prediction scores
51
- :param vec_weight: weight factor for vector-based similarity scores
52
- :param threshold: minimum confidence threshold for predictions [0.0-1.0]
43
+ :param model_name: If set to None, PP-OCRv5_server_rec is used.
44
+ :param model_dir: Model storage path.
45
+ :param input_shape: Input image size for the model in the format (C, H, W).
46
+ :param device: Device for inference.
47
+ :param precision: Precision for TensorRT.
48
+ :param cpu_threads: Number of threads to use for inference on CPUs.
53
49
  :param kwargs: reserved for future extensions
54
50
  """
55
- self._batch_size = batch_size
56
- self._threshold = threshold
57
- self._ocr_model = TextRecognition(
51
+ self._ocr_model = TextRecognition( # takes 5 ~ 12 sec to init
58
52
  model_name=model_name,
59
53
  model_dir=model_dir,
60
54
  input_shape=input_shape,
61
55
  device=device,
62
56
  precision=precision,
63
57
  cpu_threads=cpu_threads,
58
+ enable_hpi=enable_hpi,
64
59
  )
65
60
 
66
61
  def predict(
67
62
  self,
68
63
  images: list[np.ndarray],
69
- top_k: int = 1,
70
- ) -> list[list[tuple[str, float]]]:
64
+ batch_size: int = 1,
65
+ ) -> list[tuple[str, float]]:
71
66
  """
72
67
  Run OCR on input images.
73
68
 
74
69
  :param images: list of np.ndarray objects to predict
75
- :param top_k: number of top candidates to return per image
76
- :return: list of lists containing (character, score)
70
+ :param batch_size: batch size for OCR inference (minimum 1)
71
+ :return: list of tuple containing (character, score)
77
72
  """
78
73
  return [
79
- [(pred.get("rec_text"), pred.get("rec_score"))]
80
- for pred in self._ocr_model.predict(images, batch_size=self._batch_size)
74
+ (pred.get("rec_text"), pred.get("rec_score"))
75
+ for pred in self._ocr_model.predict(images, batch_size=batch_size)
81
76
  ]
82
77
 
83
78
  @staticmethod
@@ -86,7 +81,7 @@ class FontOCR:
86
81
  render_font: ImageFont.FreeTypeFont,
87
82
  is_reflect: bool = False,
88
83
  size: int = 64,
89
- ) -> Image.Image | None:
84
+ ) -> Image.Image:
90
85
  """
91
86
  Render a single character into an RGB square image.
92
87
 
@@ -107,10 +102,6 @@ class FontOCR:
107
102
  if is_reflect:
108
103
  img = img.transpose(Transpose.FLIP_LEFT_RIGHT)
109
104
 
110
- img_np = np.array(img)
111
- if np.unique(img_np).size == 1:
112
- return None
113
-
114
105
  return img
115
106
 
116
107
  @staticmethod
@@ -119,7 +110,7 @@ class FontOCR:
119
110
  render_font: ImageFont.FreeTypeFont,
120
111
  is_reflect: bool = False,
121
112
  size: int = 64,
122
- ) -> np.ndarray | None:
113
+ ) -> np.ndarray:
123
114
  """
124
115
  Render a single character into an RGB square image.
125
116
 
@@ -140,11 +131,7 @@ class FontOCR:
140
131
  if is_reflect:
141
132
  img = img.transpose(Transpose.FLIP_LEFT_RIGHT)
142
133
 
143
- img_np = np.array(img)
144
- if np.unique(img_np).size == 1:
145
- return None
146
-
147
- return img_np
134
+ return np.array(img)
148
135
 
149
136
  @staticmethod
150
137
  def render_text_image(
@@ -176,32 +163,56 @@ class FontOCR:
176
163
  return img
177
164
 
178
165
  @staticmethod
179
- def _chunked(seq: list[T], size: int) -> Generator[list[T], None, None]:
166
+ def load_image_array_from_bytes(data: bytes) -> np.ndarray:
180
167
  """
181
- Yield successive chunks of `seq` of length `size`.
168
+ Decode image bytes into an RGB NumPy array.
169
+
170
+ Reads common image formats (e.g. PNG/JPEG/WebP) from an
171
+ in-memory byte buffer using Pillow, converts the image to RGB,
172
+ and returns a NumPy array suitable for OCR inference.
173
+
174
+ :param data: Image file content as raw bytes.
175
+ :return: NumPy array of shape (H, W, 3), dtype=uint8, in RGB order.
176
+ :raises PIL.UnidentifiedImageError, OSError: If input bytes cannot be decoded.
182
177
  """
183
- for i in range(0, len(seq), size):
184
- yield seq[i : i + size]
178
+ with Image.open(io.BytesIO(data)) as im:
179
+ im = im.convert("RGB")
180
+ return np.asarray(im)
185
181
 
182
+ @staticmethod
183
+ def load_render_font(
184
+ font_path: Path | str, char_size: int = 52
185
+ ) -> ImageFont.FreeTypeFont:
186
+ """
187
+ Load a FreeType font face at the given pixel size for rendering helpers.
186
188
 
187
- _font_ocr: FontOCR | None = None
189
+ :param font_path: Path to a TTF/OTF font file.
190
+ :param char_size: Target glyph size in pixels (e.g. 52).
191
+ :return: A PIL `ImageFont.FreeTypeFont` instance.
192
+ :raises OSError: If the font file cannot be opened by PIL.
193
+ """
194
+ return ImageFont.truetype(str(font_path), char_size)
188
195
 
196
+ @staticmethod
197
+ def extract_font_charset(font_path: Path | str) -> set[str]:
198
+ """
199
+ Extract the set of Unicode characters encoded by a TrueType/OpenType font.
189
200
 
190
- def get_font_ocr(
191
- model_name: str | None = None,
192
- model_dir: str | None = None,
193
- input_shape: tuple[int, int, int] | None = None,
194
- batch_size: int = 32,
195
- ) -> FontOCR:
196
- """
197
- Return the singleton FontOCR, initializing it on first use.
198
- """
199
- global _font_ocr
200
- if _font_ocr is None:
201
- _font_ocr = FontOCR(
202
- model_name=model_name,
203
- model_dir=model_dir,
204
- input_shape=input_shape,
205
- batch_size=batch_size,
206
- )
207
- return _font_ocr
201
+ This reads the font's best available character map (cmap) and returns the
202
+ corresponding set of characters.
203
+
204
+ :param font_path: Path to a TTF/OTF font file.
205
+ :return: A set of Unicode characters present in the font's cmap.
206
+ """
207
+ with TTFont(font_path) as font_ttf:
208
+ cmap = font_ttf.getBestCmap() or {}
209
+
210
+ charset: set[str] = set()
211
+ for cp in cmap:
212
+ # guard against invalid/surrogate code points
213
+ if 0 <= cp <= 0x10FFFF and not (0xD800 <= cp <= 0xDFFF):
214
+ try:
215
+ charset.add(chr(cp))
216
+ except ValueError:
217
+ continue
218
+ return charset
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.utils.fontocr.loader
4
+ -------------------------------------
5
+
6
+ Lazily load the FontOCR class.
7
+ """
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING
11
+
12
+ from novel_downloader.models import FontOCRConfig
13
+
14
+ if TYPE_CHECKING:
15
+ from .core import FontOCR
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _FONT_OCR: "FontOCR | None" = None
20
+
21
+
22
+ def get_font_ocr(cfg: FontOCRConfig) -> "FontOCR | None":
23
+ """
24
+ Try to initialize and return a singleton FontOCR instance.
25
+ Returns None if FontOCR or its dependencies are not available.
26
+ """
27
+ global _FONT_OCR
28
+ if _FONT_OCR is None:
29
+ try:
30
+ from .core import FontOCR
31
+
32
+ _FONT_OCR = FontOCR(
33
+ model_name=cfg.model_name,
34
+ model_dir=cfg.model_dir,
35
+ input_shape=cfg.input_shape,
36
+ device=cfg.device,
37
+ precision=cfg.precision,
38
+ cpu_threads=cfg.cpu_threads,
39
+ enable_hpi=cfg.enable_hpi,
40
+ )
41
+ except ImportError:
42
+ logger.warning(
43
+ "FontOCR dependency not available "
44
+ "(paddleocr / numpy / pillow / fonttools). "
45
+ "Font decoding will be skipped."
46
+ )
47
+ return None
48
+ except Exception as e:
49
+ logger.warning("FontOCR initialization failed: %s", e, exc_info=True)
50
+ return None
51
+
52
+ return _FONT_OCR
@@ -4,17 +4,17 @@ novel_downloader.utils.logger
4
4
  -----------------------------
5
5
 
6
6
  Provides a configurable logging setup for Python applications.
7
- Log files are rotated daily and named with the given logger name and current date.
8
7
  """
9
8
 
9
+ from __future__ import annotations
10
+
10
11
  __all__ = ["setup_logging"]
11
12
 
12
13
  import logging
13
- from datetime import datetime
14
14
  from logging.handlers import TimedRotatingFileHandler
15
15
  from pathlib import Path
16
16
 
17
- from .constants import LOGGER_DIR, LOGGER_NAME
17
+ from .constants import LOGGER_DIR, PACKAGE_NAME
18
18
 
19
19
  LOG_LEVELS: dict[str, int] = {
20
20
  "DEBUG": logging.DEBUG,
@@ -22,75 +22,99 @@ LOG_LEVELS: dict[str, int] = {
22
22
  "WARNING": logging.WARNING,
23
23
  "ERROR": logging.ERROR,
24
24
  }
25
+ _MUTE_LOGGERS: set[str] = {
26
+ "fontTools.ttLib.tables._p_o_s_t",
27
+ }
28
+
29
+
30
+ def _normalize_level(level: int | str) -> int:
31
+ if isinstance(level, int):
32
+ return level
33
+ if isinstance(level, str):
34
+ lvl = LOG_LEVELS.get(level.upper())
35
+ if isinstance(lvl, int):
36
+ return lvl
37
+ return logging.INFO
25
38
 
26
39
 
27
40
  def setup_logging(
28
- log_filename_prefix: str | None = None,
29
- log_level: str | None = None,
41
+ log_filename: str | None = None,
42
+ console_level: int | str = "INFO",
43
+ file_level: int | str = "DEBUG",
30
44
  log_dir: str | Path | None = None,
45
+ *,
46
+ console: bool = True,
47
+ file: bool = True,
48
+ backup_count: int = 7,
49
+ when: str = "midnight",
31
50
  ) -> logging.Logger:
32
51
  """
33
- Create and configure a logger for both console and rotating file output.
52
+ Create and configure a package logger with optional console and file handlers.
34
53
 
35
- :param log_filename_prefix: Prefix for the log file name.
36
- :param log_level: Minimum log level to show in console
37
- ("DEBUG", "INFO", "WARNING", "ERROR")
54
+ :param log_filename: Base log file name (without date suffix).
55
+ :param console_level: Minimum level for the console handler (string or int).
56
+ :param file_level: Minimum level for the file handler (string or int).
38
57
  :param log_dir: Directory where log files will be saved.
39
- :return: A fully configured logger instance.
58
+ :param console: Add a console handler.
59
+ :param file: Add a file handler.
60
+ :param backup_count: How many rotated files to keep.
61
+ :param when: Rotation interval for TimedRotatingFileHandler (e.g., "midnight").
62
+ :return: The configured logger.
40
63
  """
41
- ft_logger = logging.getLogger("fontTools.ttLib.tables._p_o_s_t")
42
- ft_logger.setLevel(logging.ERROR)
43
- ft_logger.propagate = False
44
-
45
- # Determine console level (default INFO)
46
- level_str: str = log_level or "INFO"
47
- console_level: int = LOG_LEVELS.get(level_str) or logging.INFO
48
-
49
- # Resolve log file path
50
- log_path = Path(log_dir) if log_dir else LOGGER_DIR
51
- log_path.mkdir(parents=True, exist_ok=True)
52
-
53
- # Resolve log file name
54
- if not log_filename_prefix:
55
- log_filename_prefix = LOGGER_NAME
56
- date_str = datetime.now().strftime("%Y-%m-%d")
57
- log_filename = log_path / f"{log_filename_prefix}_{date_str}.log"
64
+ # Tame noisy third-party loggers
65
+ for name in _MUTE_LOGGERS:
66
+ ml = logging.getLogger(name)
67
+ ml.setLevel(logging.ERROR)
68
+ ml.propagate = False
58
69
 
59
- # Create or retrieve logger
60
- logger = logging.getLogger(LOGGER_NAME)
61
- logger.setLevel(logging.DEBUG) # Capture everything, filter by handlers
62
- logger.propagate = False
70
+ logger = logging.getLogger(PACKAGE_NAME)
71
+ logger.setLevel(logging.DEBUG)
72
+ logger.propagate = False # otherwise may affected by PaddleOCR
63
73
 
64
74
  # Clear existing handlers to avoid duplicate logs
65
75
  if logger.hasHandlers():
66
76
  logger.handlers.clear()
67
77
 
68
- # File handler: rotates at midnight, keeps 7 days of logs
69
- file_handler = TimedRotatingFileHandler(
70
- filename=str(log_filename),
71
- when="midnight",
72
- interval=1,
73
- backupCount=7,
74
- encoding="utf-8",
75
- utc=False,
76
- )
77
- file_formatter = logging.Formatter(
78
- fmt="%(asctime)s [%(levelname)s] %(name)s.%(funcName)s: %(message)s",
79
- datefmt="%Y-%m-%d %H:%M:%S",
80
- )
81
- file_handler.setFormatter(file_formatter)
82
- file_handler.setLevel(logging.DEBUG)
83
- logger.addHandler(file_handler)
78
+ # File handler (rotates daily)
79
+ if file:
80
+ file_level = _normalize_level(file_level)
81
+
82
+ base_dir = Path(log_dir) if log_dir else LOGGER_DIR
83
+ base_dir.mkdir(parents=True, exist_ok=True)
84
+ base_name = log_filename or PACKAGE_NAME
85
+ log_path = base_dir / f"{base_name}.log"
86
+
87
+ fh = TimedRotatingFileHandler(
88
+ filename=log_path,
89
+ when=when,
90
+ interval=1,
91
+ backupCount=backup_count,
92
+ encoding="utf-8",
93
+ utc=False,
94
+ delay=True,
95
+ )
96
+
97
+ file_formatter = logging.Formatter(
98
+ fmt="%(asctime)s [%(levelname)s] %(name)s.%(funcName)s: %(message)s",
99
+ datefmt="%Y-%m-%d %H:%M:%S",
100
+ )
101
+ fh.setFormatter(file_formatter)
102
+ fh.setLevel(file_level)
103
+ logger.addHandler(fh)
104
+
105
+ print(f"Logging to {log_path}")
84
106
 
85
107
  # Console handler
86
- console_handler = logging.StreamHandler()
87
- console_formatter = logging.Formatter(
88
- fmt="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S"
89
- )
90
- console_handler.setFormatter(console_formatter)
91
- console_handler.setLevel(console_level)
92
- logger.addHandler(console_handler)
93
-
94
- print(f"Logging to {log_path}")
108
+ if console:
109
+ console_level = _normalize_level(console_level)
110
+
111
+ console_handler = logging.StreamHandler()
112
+ console_formatter = logging.Formatter(
113
+ fmt="%(asctime)s [%(levelname)s] %(message)s",
114
+ datefmt="%H:%M:%S",
115
+ )
116
+ console_handler.setFormatter(console_formatter)
117
+ console_handler.setLevel(console_level)
118
+ logger.addHandler(console_handler)
95
119
 
96
120
  return logger
@@ -8,7 +8,6 @@ Utilities for handling HTTP requests and downloading remote resources.
8
8
 
9
9
  __all__ = ["download"]
10
10
 
11
- import logging
12
11
  from pathlib import Path
13
12
  from typing import Literal
14
13
  from urllib.parse import unquote, urlparse
@@ -19,10 +18,7 @@ from urllib3.util.retry import Retry
19
18
 
20
19
  from .constants import DEFAULT_HEADERS
21
20
  from .file_utils import sanitize_filename
22
- from .file_utils.io import _get_non_conflicting_path, write_file
23
-
24
- logger = logging.getLogger(__name__)
25
- _DEFAULT_CHUNK_SIZE = 8192 # 8KB per chunk for streaming downloads
21
+ from .file_utils.io import _unique_path, write_file
26
22
 
27
23
 
28
24
  def _normalize_url(url: str) -> str:
@@ -37,8 +33,8 @@ def _normalize_url(url: str) -> str:
37
33
 
38
34
 
39
35
  def _build_filepath(
40
- folder: Path,
41
36
  url: str,
37
+ folder: Path,
42
38
  filename: str | None,
43
39
  default_suffix: str,
44
40
  on_exist: Literal["overwrite", "skip", "rename"],
@@ -48,20 +44,18 @@ def _build_filepath(
48
44
 
49
45
  raw_name = filename or url_path.name or "unnamed"
50
46
  name = sanitize_filename(raw_name)
51
- suffix = default_suffix or url_path.suffix
52
- if suffix and not suffix.startswith("."):
53
- suffix = "." + suffix
54
47
 
55
- file_path = folder / name
56
- if not file_path.suffix and suffix:
57
- file_path = file_path.with_suffix(suffix)
48
+ if "." not in name and (url_path.suffix or default_suffix):
49
+ name += url_path.suffix or default_suffix
58
50
 
51
+ file_path = folder / name
59
52
  if on_exist == "rename":
60
- file_path = _get_non_conflicting_path(file_path)
53
+ file_path = _unique_path(file_path)
54
+
61
55
  return file_path
62
56
 
63
57
 
64
- def _make_session(
58
+ def _new_session(
65
59
  retries: int,
66
60
  backoff: float,
67
61
  headers: dict[str, str] | None,
@@ -72,7 +66,7 @@ def _make_session(
72
66
  retry = Retry(
73
67
  total=retries,
74
68
  backoff_factor=backoff,
75
- status_forcelist=[429, 500, 502, 503, 504],
69
+ status_forcelist=[413, 429, 500, 502, 503, 504],
76
70
  allowed_methods={"GET", "HEAD", "OPTIONS"},
77
71
  )
78
72
  adapter = HTTPAdapter(max_retries=retry)
@@ -90,10 +84,8 @@ def download(
90
84
  retries: int = 3,
91
85
  backoff: float = 0.5,
92
86
  headers: dict[str, str] | None = None,
93
- stream: bool = False,
94
87
  on_exist: Literal["overwrite", "skip", "rename"] = "overwrite",
95
88
  default_suffix: str = "",
96
- chunk_size: int = _DEFAULT_CHUNK_SIZE,
97
89
  ) -> Path | None:
98
90
  """
99
91
  Download a URL to disk, with retries, optional rename/skip, and cleanup on failure.
@@ -105,10 +97,8 @@ def download(
105
97
  :param retries: GET retry count.
106
98
  :param backoff: exponential backoff base.
107
99
  :param headers: optional headers.
108
- :param stream: Whether to stream the response.
109
100
  :param on_exist: if 'skip', return filepath; if 'rename', auto-rename.
110
101
  :param default_suffix: used if no suffix in URL or filename.
111
- :param chunk_size: streaming chunk size.
112
102
  :return: path to the downloaded file.
113
103
  """
114
104
  url = _normalize_url(url)
@@ -117,8 +107,8 @@ def download(
117
107
  folder.mkdir(parents=True, exist_ok=True)
118
108
 
119
109
  save_path = _build_filepath(
120
- folder,
121
110
  url,
111
+ folder,
122
112
  filename,
123
113
  default_suffix,
124
114
  on_exist,
@@ -126,34 +116,20 @@ def download(
126
116
 
127
117
  # Handle existing file
128
118
  if save_path.exists() and on_exist == "skip":
129
- logger.debug("Skipping download; file exists: %s", save_path)
130
119
  return save_path
131
120
 
132
- with _make_session(retries, backoff, headers) as session:
121
+ with _new_session(retries, backoff, headers) as session:
133
122
  try:
134
- resp = session.get(url, timeout=timeout, stream=stream)
123
+ resp = session.get(url, timeout=timeout)
135
124
  resp.raise_for_status()
136
- except Exception as e:
137
- logger.warning("[download] request failed: %s", e)
138
- return None
139
125
 
140
- # Write to disk
141
- if stream:
142
- try:
143
- with open(save_path, "wb") as f:
144
- for chunk in resp.iter_content(chunk_size=chunk_size):
145
- if chunk:
146
- f.write(chunk)
147
- return save_path
148
- except Exception as e:
149
- logger.warning("[download] write failed: %s", e)
150
- save_path.unlink(missing_ok=True)
151
- return None
152
- else:
126
+ # Write to disk
153
127
  return write_file(
154
128
  content=resp.content,
155
129
  filepath=save_path,
156
- write_mode="wb",
157
130
  on_exist=on_exist,
158
131
  )
132
+ except Exception:
133
+ return None
134
+
159
135
  return None
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.utils.node_decryptor
4
+ -------------------------------------
5
+
6
+ Provides NodeDecryptor, which ensures a Node.js environment,
7
+ downloads or installs the required JS modules (Fock + decrypt script),
8
+ and invokes a Node.js subprocess to decrypt chapter content.
9
+ """
10
+
11
+ __all__ = ["get_decryptor"]
12
+
13
+ from .decryptor import get_decryptor