novel-downloader 1.2.2__py3-none-any.whl → 1.3.1__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 (128) hide show
  1. novel_downloader/__init__.py +1 -2
  2. novel_downloader/cli/__init__.py +0 -1
  3. novel_downloader/cli/clean.py +2 -10
  4. novel_downloader/cli/download.py +16 -22
  5. novel_downloader/cli/interactive.py +0 -1
  6. novel_downloader/cli/main.py +1 -3
  7. novel_downloader/cli/settings.py +8 -8
  8. novel_downloader/config/__init__.py +0 -1
  9. novel_downloader/config/adapter.py +32 -27
  10. novel_downloader/config/loader.py +116 -108
  11. novel_downloader/config/models.py +35 -29
  12. novel_downloader/config/site_rules.py +2 -4
  13. novel_downloader/core/__init__.py +0 -1
  14. novel_downloader/core/downloaders/__init__.py +4 -4
  15. novel_downloader/core/downloaders/base/__init__.py +14 -0
  16. novel_downloader/core/downloaders/{base_async_downloader.py → base/base_async.py} +49 -53
  17. novel_downloader/core/downloaders/{base_downloader.py → base/base_sync.py} +64 -43
  18. novel_downloader/core/downloaders/biquge/__init__.py +12 -0
  19. novel_downloader/core/downloaders/biquge/biquge_sync.py +25 -0
  20. novel_downloader/core/downloaders/common/__init__.py +14 -0
  21. novel_downloader/core/downloaders/{common_asynb_downloader.py → common/common_async.py} +42 -33
  22. novel_downloader/core/downloaders/{common_downloader.py → common/common_sync.py} +33 -21
  23. novel_downloader/core/downloaders/qidian/__init__.py +10 -0
  24. novel_downloader/core/downloaders/{qidian_downloader.py → qidian/qidian_sync.py} +79 -62
  25. novel_downloader/core/factory/__init__.py +4 -5
  26. novel_downloader/core/factory/{downloader_factory.py → downloader.py} +25 -26
  27. novel_downloader/core/factory/{parser_factory.py → parser.py} +12 -14
  28. novel_downloader/core/factory/{requester_factory.py → requester.py} +29 -16
  29. novel_downloader/core/factory/{saver_factory.py → saver.py} +4 -9
  30. novel_downloader/core/interfaces/__init__.py +8 -9
  31. novel_downloader/core/interfaces/{async_downloader_protocol.py → async_downloader.py} +4 -5
  32. novel_downloader/core/interfaces/{async_requester_protocol.py → async_requester.py} +23 -12
  33. novel_downloader/core/interfaces/{parser_protocol.py → parser.py} +11 -6
  34. novel_downloader/core/interfaces/{saver_protocol.py → saver.py} +2 -3
  35. novel_downloader/core/interfaces/{downloader_protocol.py → sync_downloader.py} +6 -7
  36. novel_downloader/core/interfaces/{requester_protocol.py → sync_requester.py} +31 -17
  37. novel_downloader/core/parsers/__init__.py +5 -4
  38. novel_downloader/core/parsers/{base_parser.py → base.py} +18 -9
  39. novel_downloader/core/parsers/biquge/__init__.py +10 -0
  40. novel_downloader/core/parsers/biquge/main_parser.py +126 -0
  41. novel_downloader/core/parsers/{common_parser → common}/__init__.py +2 -3
  42. novel_downloader/core/parsers/{common_parser → common}/helper.py +13 -13
  43. novel_downloader/core/parsers/{common_parser → common}/main_parser.py +15 -9
  44. novel_downloader/core/parsers/{qidian_parser → qidian}/__init__.py +2 -3
  45. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/__init__.py +2 -3
  46. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_encrypted.py +40 -48
  47. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_normal.py +17 -21
  48. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_router.py +10 -9
  49. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/main_parser.py +14 -10
  50. novel_downloader/core/parsers/{qidian_parser → qidian}/session/__init__.py +2 -3
  51. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_encrypted.py +36 -44
  52. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_normal.py +19 -23
  53. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_router.py +10 -9
  54. novel_downloader/core/parsers/{qidian_parser → qidian}/session/main_parser.py +14 -10
  55. novel_downloader/core/parsers/{qidian_parser → qidian}/session/node_decryptor.py +7 -10
  56. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/__init__.py +2 -3
  57. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/book_info_parser.py +5 -6
  58. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/helpers.py +7 -8
  59. novel_downloader/core/requesters/__init__.py +9 -5
  60. novel_downloader/core/requesters/base/__init__.py +16 -0
  61. novel_downloader/core/requesters/{base_async_session.py → base/async_session.py} +177 -73
  62. novel_downloader/core/requesters/base/browser.py +340 -0
  63. novel_downloader/core/requesters/base/session.py +364 -0
  64. novel_downloader/core/requesters/biquge/__init__.py +12 -0
  65. novel_downloader/core/requesters/biquge/session.py +90 -0
  66. novel_downloader/core/requesters/{common_requester → common}/__init__.py +4 -5
  67. novel_downloader/core/requesters/common/async_session.py +96 -0
  68. novel_downloader/core/requesters/common/session.py +113 -0
  69. novel_downloader/core/requesters/qidian/__init__.py +21 -0
  70. novel_downloader/core/requesters/qidian/broswer.py +307 -0
  71. novel_downloader/core/requesters/qidian/session.py +287 -0
  72. novel_downloader/core/savers/__init__.py +5 -3
  73. novel_downloader/core/savers/{base_saver.py → base.py} +12 -13
  74. novel_downloader/core/savers/biquge.py +25 -0
  75. novel_downloader/core/savers/{common_saver → common}/__init__.py +2 -3
  76. novel_downloader/core/savers/{common_saver/common_epub.py → common/epub.py} +23 -51
  77. novel_downloader/core/savers/{common_saver → common}/main_saver.py +43 -9
  78. novel_downloader/core/savers/{common_saver/common_txt.py → common/txt.py} +16 -46
  79. novel_downloader/core/savers/epub_utils/__init__.py +0 -1
  80. novel_downloader/core/savers/epub_utils/css_builder.py +13 -7
  81. novel_downloader/core/savers/epub_utils/initializer.py +4 -5
  82. novel_downloader/core/savers/epub_utils/text_to_html.py +2 -3
  83. novel_downloader/core/savers/epub_utils/volume_intro.py +1 -3
  84. novel_downloader/core/savers/{qidian_saver.py → qidian.py} +12 -6
  85. novel_downloader/locales/en.json +8 -4
  86. novel_downloader/locales/zh.json +5 -1
  87. novel_downloader/resources/config/settings.toml +88 -0
  88. novel_downloader/utils/cache.py +2 -2
  89. novel_downloader/utils/chapter_storage.py +340 -0
  90. novel_downloader/utils/constants.py +6 -4
  91. novel_downloader/utils/crypto_utils.py +3 -3
  92. novel_downloader/utils/file_utils/__init__.py +0 -1
  93. novel_downloader/utils/file_utils/io.py +12 -17
  94. novel_downloader/utils/file_utils/normalize.py +1 -3
  95. novel_downloader/utils/file_utils/sanitize.py +2 -9
  96. novel_downloader/utils/fontocr/__init__.py +0 -1
  97. novel_downloader/utils/fontocr/ocr_v1.py +19 -22
  98. novel_downloader/utils/fontocr/ocr_v2.py +147 -60
  99. novel_downloader/utils/hash_store.py +19 -20
  100. novel_downloader/utils/hash_utils.py +0 -1
  101. novel_downloader/utils/i18n.py +3 -4
  102. novel_downloader/utils/logger.py +5 -6
  103. novel_downloader/utils/model_loader.py +5 -8
  104. novel_downloader/utils/network.py +9 -10
  105. novel_downloader/utils/state.py +6 -7
  106. novel_downloader/utils/text_utils/__init__.py +0 -1
  107. novel_downloader/utils/text_utils/chapter_formatting.py +2 -7
  108. novel_downloader/utils/text_utils/diff_display.py +0 -1
  109. novel_downloader/utils/text_utils/font_mapping.py +1 -4
  110. novel_downloader/utils/text_utils/text_cleaning.py +0 -1
  111. novel_downloader/utils/time_utils/__init__.py +0 -1
  112. novel_downloader/utils/time_utils/datetime_utils.py +8 -10
  113. novel_downloader/utils/time_utils/sleep_utils.py +1 -3
  114. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/METADATA +14 -17
  115. novel_downloader-1.3.1.dist-info/RECORD +127 -0
  116. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/WHEEL +1 -1
  117. novel_downloader/core/requesters/base_browser.py +0 -214
  118. novel_downloader/core/requesters/base_session.py +0 -246
  119. novel_downloader/core/requesters/common_requester/common_async_session.py +0 -98
  120. novel_downloader/core/requesters/common_requester/common_session.py +0 -126
  121. novel_downloader/core/requesters/qidian_requester/__init__.py +0 -22
  122. novel_downloader/core/requesters/qidian_requester/qidian_broswer.py +0 -396
  123. novel_downloader/core/requesters/qidian_requester/qidian_session.py +0 -202
  124. novel_downloader/resources/config/settings.yaml +0 -76
  125. novel_downloader-1.2.2.dist-info/RECORD +0 -115
  126. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/entry_points.txt +0 -0
  127. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/licenses/LICENSE +0 -0
  128. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
- novel_downloader.core.requesters.base_async_session
3
+ novel_downloader.core.requesters.base.async_session
5
4
  ---------------------------------------------------
6
5
 
7
6
  This module defines the BaseAsyncSession class, which provides asynchronous
@@ -12,8 +11,11 @@ cookie handling, and defines abstract methods for subclasses.
12
11
 
13
12
  import abc
14
13
  import asyncio
14
+ import logging
15
+ import random
15
16
  import time
16
- from typing import Any, Dict, Literal, Optional, Union
17
+ import types
18
+ from typing import Any, Literal, Self
17
19
 
18
20
  import aiohttp
19
21
  from aiohttp import ClientResponse, ClientSession, ClientTimeout, TCPConnector
@@ -25,7 +27,8 @@ from novel_downloader.utils.constants import DEFAULT_USER_HEADERS
25
27
 
26
28
  class RateLimiter:
27
29
  """
28
- Simple async token-bucket rate limiter: ensures no more than rate_per_sec
30
+ Simple async token-bucket rate limiter:
31
+ ensures no more than rate_per_sec
29
32
  requests are started per second, across all coroutines.
30
33
  """
31
34
 
@@ -40,7 +43,8 @@ class RateLimiter:
40
43
  elapsed = now - self._last
41
44
  delay = self._interval - elapsed
42
45
  if delay > 0:
43
- await asyncio.sleep(delay)
46
+ jitter = random.uniform(0, 0.3)
47
+ await asyncio.sleep(delay + jitter)
44
48
  self._last = time.monotonic()
45
49
 
46
50
 
@@ -61,10 +65,10 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
61
65
  def is_async(self) -> Literal[True]:
62
66
  return True
63
67
 
64
- def _init_session(
68
+ def __init__(
65
69
  self,
66
70
  config: RequesterConfig,
67
- cookies: Optional[Dict[str, str]] = None,
71
+ cookies: dict[str, str] | None = None,
68
72
  ) -> None:
69
73
  """
70
74
  Initialize the async session with configuration.
@@ -74,26 +78,30 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
74
78
  :param cookies: Optional initial cookies to set on the session.
75
79
  """
76
80
  self._config = config
77
- self._timeout = config.timeout
78
81
  self._retry_times = config.retry_times
79
- self._retry_interval = config.retry_interval
82
+ self._retry_interval = config.backoff_factor
83
+ self._timeout = config.timeout
84
+ self._max_rps = config.max_rps
85
+ self._max_connections = config.max_connections
86
+
80
87
  self._cookies = cookies or {}
81
88
  self._headers = DEFAULT_USER_HEADERS.copy()
82
- self._session: Optional[ClientSession] = None
83
- self._rate_limiter: Optional[RateLimiter] = None
89
+ self._session: ClientSession | None = None
90
+ self._rate_limiter: RateLimiter | None = None
84
91
 
85
- async def _setup(self) -> None:
92
+ self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
93
+
94
+ self._init_session()
95
+
96
+ def _init_session(self) -> None:
86
97
  """
87
98
  Set up the aiohttp.ClientSession with timeout, connector, headers, and cookies.
88
99
  """
89
- max_rps = getattr(self._config, "max_rps", None)
90
- if max_rps is not None:
91
- self._rate_limiter = RateLimiter(max_rps)
100
+ if self._max_rps is not None:
101
+ self._rate_limiter = RateLimiter(self._max_rps)
92
102
 
93
103
  timeout = ClientTimeout(total=self._timeout)
94
- connector = TCPConnector(
95
- limit_per_host=getattr(self._config, "max_connections", 10)
96
- )
104
+ connector = TCPConnector(limit_per_host=self._max_connections)
97
105
  self._session = ClientSession(
98
106
  timeout=timeout,
99
107
  connector=connector,
@@ -101,7 +109,13 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
101
109
  cookies=self._cookies,
102
110
  )
103
111
 
104
- async def login(self, max_retries: int = 3, manual_login: bool = False) -> bool:
112
+ async def login(
113
+ self,
114
+ username: str = "",
115
+ password: str = "",
116
+ manual_login: bool = False,
117
+ **kwargs: Any,
118
+ ) -> bool:
105
119
  """
106
120
  Attempt to log in asynchronously.
107
121
  Override in subclasses that require authentication.
@@ -115,7 +129,9 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
115
129
 
116
130
  @abc.abstractmethod
117
131
  async def get_book_info(
118
- self, book_id: str, wait_time: Optional[float] = None
132
+ self,
133
+ book_id: str,
134
+ **kwargs: Any,
119
135
  ) -> str:
120
136
  """
121
137
  Fetch the raw HTML (or JSON) of the book info page asynchronously.
@@ -128,7 +144,10 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
128
144
 
129
145
  @abc.abstractmethod
130
146
  async def get_book_chapter(
131
- self, book_id: str, chapter_id: str, wait_time: Optional[float] = None
147
+ self,
148
+ book_id: str,
149
+ chapter_id: str,
150
+ **kwargs: Any,
132
151
  ) -> str:
133
152
  """
134
153
  Fetch the raw HTML (or JSON) of a single chapter asynchronously.
@@ -140,7 +159,11 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
140
159
  """
141
160
  ...
142
161
 
143
- async def get_bookcase(self, wait_time: Optional[float] = None) -> str:
162
+ async def get_bookcase(
163
+ self,
164
+ page: int = 1,
165
+ **kwargs: Any,
166
+ ) -> str:
144
167
  """
145
168
  Optional: Retrieve the HTML content of the authenticated user's bookcase page.
146
169
  Subclasses that support user login/bookcase should override this.
@@ -162,17 +185,12 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
162
185
  :return: The response body as text.
163
186
  :raises: aiohttp.ClientError on final failure.
164
187
  """
165
- if self._session is None:
166
- await self._setup()
167
- if self._session is None:
168
- raise RuntimeError("Session not initialized after setup")
169
-
170
188
  if self._rate_limiter:
171
189
  await self._rate_limiter.wait()
172
190
 
173
191
  for attempt in range(self._retry_times + 1):
174
192
  try:
175
- async with self._session.get(url, **kwargs) as resp:
193
+ async with self.session.get(url, **kwargs) as resp:
176
194
  resp.raise_for_status()
177
195
  text: str = await resp.text()
178
196
  return text
@@ -185,7 +203,10 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
185
203
  raise RuntimeError("Unreachable code reached in fetch()")
186
204
 
187
205
  async def get(
188
- self, url: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any
206
+ self,
207
+ url: str,
208
+ params: dict[str, Any] | None = None,
209
+ **kwargs: Any,
189
210
  ) -> ClientResponse:
190
211
  """
191
212
  Send an HTTP GET request asynchronously.
@@ -196,20 +217,13 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
196
217
  :return: aiohttp.ClientResponse object.
197
218
  :raises RuntimeError: If the session is not initialized.
198
219
  """
199
- if self._session is None:
200
- await self._setup()
201
- if self._session is None:
202
- raise RuntimeError("Session not initialized after setup")
203
-
204
- if self._rate_limiter:
205
- await self._rate_limiter.wait()
206
- return await self._session.get(url, params=params, **kwargs)
220
+ return await self._request("GET", url, params=params, **kwargs)
207
221
 
208
222
  async def post(
209
223
  self,
210
224
  url: str,
211
- data: Optional[Union[Dict[str, Any], bytes]] = None,
212
- json: Optional[Dict[str, Any]] = None,
225
+ data: dict[str, Any] | bytes | None = None,
226
+ json: dict[str, Any] | None = None,
213
227
  **kwargs: Any,
214
228
  ) -> ClientResponse:
215
229
  """
@@ -222,14 +236,7 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
222
236
  :return: aiohttp.ClientResponse object.
223
237
  :raises RuntimeError: If the session is not initialized.
224
238
  """
225
- if self._session is None:
226
- await self._setup()
227
- if self._session is None:
228
- raise RuntimeError("Session not initialized after setup")
229
-
230
- if self._rate_limiter:
231
- await self._rate_limiter.wait()
232
- return await self._session.post(url, data=data, json=json, **kwargs)
239
+ return await self._request("POST", url, data=data, json=json, **kwargs)
233
240
 
234
241
  @property
235
242
  def session(self) -> ClientSession:
@@ -243,41 +250,106 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
243
250
  return self._session
244
251
 
245
252
  @property
246
- def timeout(self) -> float:
247
- """Return the default timeout setting."""
248
- return self._timeout
253
+ def cookies(self) -> dict[str, str]:
254
+ """
255
+ Get the current session cookies.
249
256
 
250
- @property
251
- def retry_times(self) -> int:
252
- """Return the maximum number of retry attempts."""
253
- return self._retry_times
257
+ :return: A dict mapping cookie names to their values.
258
+ """
259
+ if self._session:
260
+ return {c.key: c.value for c in self._session.cookie_jar}
261
+ else:
262
+ return self._cookies
254
263
 
255
264
  @property
256
- def retry_interval(self) -> float:
257
- """Return the base interval (in seconds) between retries."""
258
- return self._retry_interval
265
+ def headers(self) -> dict[str, str]:
266
+ """
267
+ Get the current session headers.
259
268
 
260
- async def update_cookies(
261
- self, cookies: Dict[str, str], overwrite: bool = True
262
- ) -> None:
269
+ :return: A dict mapping header names to their values.
270
+ """
271
+ if self._session:
272
+ return dict(self._session.headers)
273
+ else:
274
+ return self._headers
275
+
276
+ def get_header(self, key: str, default: Any = None) -> Any:
263
277
  """
264
- Update cookies for the current session and internal cache.
278
+ Retrieve a specific header value by name.
265
279
 
266
- :param cookies: New cookies to merge.
267
- :param overwrite: If True, replace existing; else, only set missing.
280
+ :param key: The header name to look up.
281
+ :param default: The value to return if the header is not present.
282
+ :return: The header value if present, else default.
268
283
  """
269
- # update internal cache
270
- if overwrite:
271
- self._cookies.update({str(k): str(v) for k, v in cookies.items()})
284
+ if self._session:
285
+ return self._session.headers.get(key, default)
272
286
  else:
273
- for k, v in cookies.items():
274
- self._cookies.setdefault(str(k), str(v))
287
+ return self._headers.get(key, default)
288
+
289
+ def update_header(self, key: str, value: str) -> None:
290
+ """
291
+ Update or add a single header in the session.
292
+
293
+ :param key: The name of the header.
294
+ :param value: The value of the header.
295
+ """
296
+ self._headers[key] = value
297
+ if self._session:
298
+ self._session.headers[key] = value
299
+
300
+ def update_headers(self, headers: dict[str, str]) -> None:
301
+ """
302
+ Update or add multiple headers in the session.
303
+
304
+ :param headers: A dictionary of header key-value pairs.
305
+ """
306
+ self._headers.update(headers)
307
+ if self._session:
308
+ self._session.headers.update(headers)
309
+
310
+ def update_cookie(self, key: str, value: str) -> None:
311
+ """
312
+ Update or add a single cookie in the session.
313
+
314
+ :param key: The name of the cookie.
315
+ :param value: The value of the cookie.
316
+ """
317
+ self._cookies[key] = value
318
+ if self._session:
319
+ self._session.cookie_jar.update_cookies({key: value})
320
+
321
+ def update_cookies(
322
+ self,
323
+ cookies: dict[str, str],
324
+ ) -> None:
325
+ """
326
+ Update or add multiple cookies in the session.
327
+
328
+ :param cookies: A dictionary of cookie key-value pairs.
329
+ """
330
+ self._cookies.update(cookies)
331
+ if self._session:
332
+ self._session.cookie_jar.update_cookies(cookies)
275
333
 
276
- # apply to live session
334
+ def clear_cookies(self) -> None:
335
+ """
336
+ Clear cookies from the session.
337
+ """
338
+ self._cookies = {}
277
339
  if self._session:
278
- self._session.cookie_jar.update_cookies(self._cookies)
340
+ self._session.cookie_jar.clear()
341
+
342
+ async def _request(
343
+ self,
344
+ method: str,
345
+ url: str,
346
+ **kwargs: Any,
347
+ ) -> ClientResponse:
348
+ if self._rate_limiter:
349
+ await self._rate_limiter.wait()
350
+ return await self.session.request(method, url, **kwargs)
279
351
 
280
- async def shutdown(self) -> None:
352
+ async def close(self) -> None:
281
353
  """
282
354
  Shutdown and clean up the session. Closes connection pool.
283
355
  """
@@ -285,16 +357,48 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
285
357
  await self._session.close()
286
358
  self._session = None
287
359
 
288
- def __getstate__(self) -> Dict[str, Any]:
360
+ def sync_close(self) -> None:
361
+ """
362
+ Sync wrapper for closing the aiohttp session
363
+ when called from sync contexts.
364
+ """
365
+ if self._session:
366
+ try:
367
+ loop = asyncio.get_running_loop()
368
+ loop.create_task(self.close())
369
+ except RuntimeError:
370
+ loop = asyncio.new_event_loop()
371
+ asyncio.set_event_loop(loop)
372
+ loop.run_until_complete(self.close())
373
+ loop.close()
374
+
375
+ async def __aenter__(self) -> Self:
376
+ if self._session is None:
377
+ self._init_session()
378
+ return self
379
+
380
+ async def __aexit__(
381
+ self,
382
+ exc_type: type[BaseException] | None,
383
+ exc_val: BaseException | None,
384
+ tb: types.TracebackType | None,
385
+ ) -> None:
386
+ await self.close()
387
+
388
+ def __del__(self) -> None:
389
+ self.sync_close()
390
+
391
+ def __getstate__(self) -> dict[str, Any]:
289
392
  """
290
393
  Prepare object state for serialization: remove unpickleable session.
291
394
  """
395
+ self.sync_close()
292
396
  state = self.__dict__.copy()
293
397
  state.pop("_session", None)
294
398
  state.pop("_rate_limiter", None)
295
399
  return state
296
400
 
297
- def __setstate__(self, state: Dict[str, Any]) -> None:
401
+ def __setstate__(self, state: dict[str, Any]) -> None:
298
402
  """
299
403
  Restore object state. Session will be lazily reinitialized on next request.
300
404
  """