aioqzone 1.9.6.dev6__tar.gz → 1.9.8.dev1__tar.gz
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.
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/PKG-INFO +2 -2
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/pyproject.toml +2 -2
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/api/__init__.py +6 -0
- aioqzone-1.9.8.dev1/src/aioqzone/api/h5/__init__.py +8 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/api/h5/model.py +47 -5
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/api/login/__init__.py +42 -9
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/api/login/_base.py +24 -5
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/exception.py +8 -1
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/message.py +6 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/model/api/__init__.py +44 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/model/api/feed.py +7 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/model/api/request.py +16 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/model/api/response.py +19 -6
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/model/protocol/__init__.py +10 -4
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/model/protocol/config.py +7 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/model/protocol/entity.py +4 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/utils/entity.py +17 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/utils/regex.py +7 -1
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/utils/retry.py +28 -1
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/utils/time.py +8 -1
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/base.py +31 -3
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/exception.py +7 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/message.py +6 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/qr/__init__.py +15 -2
- aioqzone-1.9.8.dev1/src/qqqr/qr/type.py +37 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/type.py +6 -0
- aioqzone-1.9.8.dev1/src/qqqr/up/__init__.py +9 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/up/_model.py +18 -5
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/up/captcha/__init__.py +22 -10
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/up/captcha/_model.py +21 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/up/captcha/capsess.py +40 -3
- aioqzone-1.9.8.dev1/src/qqqr/up/captcha/click/__init__.py +8 -0
- aioqzone-1.9.8.dev1/src/qqqr/up/captcha/click/_types.py +21 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/up/captcha/pil_utils.py +8 -0
- aioqzone-1.9.8.dev1/src/qqqr/up/captcha/select/__init__.py +8 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/up/captcha/select/_types.py +31 -1
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/up/captcha/slide/__init__.py +5 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/up/captcha/slide/_types.py +19 -6
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/up/h5.py +17 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/up/web.py +38 -7
- aioqzone-1.9.8.dev1/src/qqqr/utils/encrypt.py +26 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/utils/iter.py +17 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/utils/net.py +7 -0
- aioqzone-1.9.6.dev6/src/aioqzone/api/h5/__init__.py +0 -3
- aioqzone-1.9.6.dev6/src/qqqr/qr/type.py +0 -24
- aioqzone-1.9.6.dev6/src/qqqr/up/__init__.py +0 -4
- aioqzone-1.9.6.dev6/src/qqqr/up/captcha/click/__init__.py +0 -3
- aioqzone-1.9.6.dev6/src/qqqr/up/captcha/click/_types.py +0 -8
- aioqzone-1.9.6.dev6/src/qqqr/up/captcha/select/__init__.py +0 -3
- aioqzone-1.9.6.dev6/src/qqqr/utils/encrypt.py +0 -9
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/README.md +0 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/__init__.py +0 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/model/__init__.py +0 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/model/api/profile.py +0 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/aioqzone/utils/__init__.py +0 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/__init__.py +0 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/constant.py +0 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/py.typed +0 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/up/encrypt.py +0 -0
- {aioqzone-1.9.6.dev6 → aioqzone-1.9.8.dev1}/src/qqqr/utils/jsjson.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aioqzone
|
|
3
|
-
Version: 1.9.
|
|
3
|
+
Version: 1.9.8.dev1
|
|
4
4
|
Summary: A Python wrapper for Qzone login and H5 APIs.
|
|
5
5
|
Keywords: qzone-api,autologin,asyncio-spider
|
|
6
6
|
Author: aioqzone
|
|
@@ -9,7 +9,7 @@ Classifier: Development Status :: 4 - Beta
|
|
|
9
9
|
Classifier: Intended Audience :: Developers
|
|
10
10
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
11
11
|
Classifier: Typing :: Typed
|
|
12
|
-
Requires-Dist: aiohttp~=3.
|
|
12
|
+
Requires-Dist: aiohttp~=3.14
|
|
13
13
|
Requires-Dist: pydantic~=2.12
|
|
14
14
|
Requires-Dist: pydantic-settings>=2.9.0
|
|
15
15
|
Requires-Dist: rsa>=4.9.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "aioqzone"
|
|
3
|
-
version = "1.9.
|
|
3
|
+
version = "1.9.8.dev1"
|
|
4
4
|
description = "A Python wrapper for Qzone login and H5 APIs."
|
|
5
5
|
authors = [{ name = "aioqzone" }]
|
|
6
6
|
maintainers = [
|
|
@@ -18,7 +18,7 @@ classifiers = [
|
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
dependencies = [
|
|
21
|
-
"aiohttp~=3.
|
|
21
|
+
"aiohttp~=3.14",
|
|
22
22
|
"pydantic~=2.12",
|
|
23
23
|
"pydantic-settings>=2.9.0",
|
|
24
24
|
"rsa>=4.9.1",
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
"""Qzone H5 API client.
|
|
2
|
+
|
|
3
|
+
Defines :class:`QzoneH5API` — the main API entry point for all Qzone H5
|
|
4
|
+
operations including feed fetching, commenting, liking, and photo upload.
|
|
5
|
+
"""
|
|
6
|
+
|
|
1
7
|
import logging
|
|
2
8
|
import typing as t
|
|
3
9
|
from os import PathLike
|
|
@@ -14,12 +20,22 @@ log = logging.getLogger(__name__)
|
|
|
14
20
|
|
|
15
21
|
|
|
16
22
|
class QzoneH5API:
|
|
23
|
+
"""Main Qzone H5 API client.
|
|
24
|
+
|
|
25
|
+
Wraps Qzone HTTP endpoints behind typed API methods. Handles login
|
|
26
|
+
state, automatic token injection, and retry-on-login-expire logic.
|
|
27
|
+
All methods delegate to :meth:`call` for actual HTTP dispatch.
|
|
28
|
+
"""
|
|
29
|
+
|
|
17
30
|
qzone_tokens: t.Dict[int, str]
|
|
31
|
+
"""Cached qzonetokens keyed by host uin."""
|
|
18
32
|
|
|
19
33
|
def __init__(
|
|
20
34
|
self, client: ClientAdapter, loginman: Loginable, *, retry_if_login_expire: bool = True
|
|
21
35
|
) -> None:
|
|
22
36
|
"""
|
|
37
|
+
:param client: network client
|
|
38
|
+
:param loginman: login manager
|
|
23
39
|
:param retry_if_login_expire: if we should retry if login expired.
|
|
24
40
|
"""
|
|
25
41
|
super().__init__()
|
|
@@ -47,6 +63,16 @@ class QzoneH5API:
|
|
|
47
63
|
self.qzone_tokens = {}
|
|
48
64
|
|
|
49
65
|
async def call(self, api: QzoneApi[TyRequest, TyResponse]) -> TyResponse:
|
|
66
|
+
"""Execute an API call with full lifecycle management.
|
|
67
|
+
|
|
68
|
+
Builds params, injects authentication (g_tk, qzonetoken, cookies),
|
|
69
|
+
dispatches HTTP request, parses response, and handles retry on
|
|
70
|
+
login expiry.
|
|
71
|
+
|
|
72
|
+
:param api: the API definition to call
|
|
73
|
+
:returns: typed response object
|
|
74
|
+
:raises RuntimeError: if max retry exceeded
|
|
75
|
+
"""
|
|
50
76
|
params: t.Dict[str, t.Any] = api.params.build_params(self.login.uin)
|
|
51
77
|
if api.http_method == "GET":
|
|
52
78
|
data = {}
|
|
@@ -89,7 +115,7 @@ class QzoneH5API:
|
|
|
89
115
|
log.debug(f"Error when validating {obj}, {api=}")
|
|
90
116
|
raise
|
|
91
117
|
else:
|
|
92
|
-
raise
|
|
118
|
+
raise RuntimeError("max retry exceeded")
|
|
93
119
|
|
|
94
120
|
async def _retry_sleep(self, *_) -> None:
|
|
95
121
|
await self.login.new_cookie()
|
|
@@ -97,7 +123,7 @@ class QzoneH5API:
|
|
|
97
123
|
async def index(self) -> IndexPageResp:
|
|
98
124
|
"""This api is the redirect page after h5 login, which is also the landing (main) page of h5 qzone.
|
|
99
125
|
|
|
100
|
-
:raise `RuntimeError`: if
|
|
126
|
+
:raise `RuntimeError`: if max retry exceeded.
|
|
101
127
|
"""
|
|
102
128
|
|
|
103
129
|
r = await self.call(IndexPageApi())
|
|
@@ -122,7 +148,7 @@ class QzoneH5API:
|
|
|
122
148
|
|
|
123
149
|
:param attach_info: The ``attach_info`` field from last call.
|
|
124
150
|
Pass an empty string equals to call :meth:`.index`.
|
|
125
|
-
:return: If success, the
|
|
151
|
+
:return: If success, the response.
|
|
126
152
|
"""
|
|
127
153
|
if not self.qzone_tokens.get(self.login.uin) or not attach_info:
|
|
128
154
|
return await self.index()
|
|
@@ -137,7 +163,7 @@ class QzoneH5API:
|
|
|
137
163
|
:param hostuin: uin of the user
|
|
138
164
|
:param attach_info: The ``attach_info`` field from last call.
|
|
139
165
|
Pass an empty string equals to call :meth:`.index`.
|
|
140
|
-
:return: If success, the
|
|
166
|
+
:return: If success, the response.
|
|
141
167
|
"""
|
|
142
168
|
if not self.qzone_tokens.get(hostuin) or not attach_info:
|
|
143
169
|
return (await self.profile(hostuin)).feedpage
|
|
@@ -213,6 +239,7 @@ class QzoneH5API:
|
|
|
213
239
|
:param content: comment content
|
|
214
240
|
:param photos: photos to be attached, usually returned by :meth:`.preupload_photos`
|
|
215
241
|
:param busi_param: optional encoded params from :obj:`FeedOperation.operation.busi_param`
|
|
242
|
+
:param feedsType: feed type
|
|
216
243
|
:param abstime: required if `appid != 311`
|
|
217
244
|
:param private: is private comment
|
|
218
245
|
|
|
@@ -265,6 +292,8 @@ class QzoneH5API:
|
|
|
265
292
|
photos: t.Optional[t.Sequence[t.Union[PhotoData, PicInfo]]] = None,
|
|
266
293
|
sync_weibo=False,
|
|
267
294
|
ugc_right: UgcRight = UgcRight.all,
|
|
295
|
+
allow_uins: t.Optional[t.Sequence[int]] = None,
|
|
296
|
+
deny_uins: t.Optional[t.Sequence[int]] = None,
|
|
268
297
|
) -> PublishMoodResp:
|
|
269
298
|
"""Publish a feed.
|
|
270
299
|
|
|
@@ -272,12 +301,18 @@ class QzoneH5API:
|
|
|
272
301
|
:param photos: photos to be attached, usually returned by :meth:`.preupload_photos`
|
|
273
302
|
:param sync_weibo: sync to weibo, default to false
|
|
274
303
|
:param ugc_right: access right, default to "Available to Everyone".
|
|
304
|
+
:param allow_uins: when ``ugc_right=part``, only these QQ users can see this feed.
|
|
305
|
+
:param deny_uins: when ``ugc_right=blacklist``, these QQ users cannot see this feed.
|
|
275
306
|
|
|
276
307
|
.. seealso:: :meth:`.preupload_photos`, :meth:`.upload_pic`
|
|
277
308
|
"""
|
|
278
309
|
photos = photos or []
|
|
310
|
+
kw = dict(locals())
|
|
311
|
+
kw.pop("self")
|
|
312
|
+
kw["allow_uins"] = kw.get("allow_uins") or []
|
|
313
|
+
kw["deny_uins"] = kw.get("deny_uins") or []
|
|
279
314
|
return await self.call(
|
|
280
|
-
PublishMoodApi(params=PublishMoodParams.model_validate(
|
|
315
|
+
PublishMoodApi(params=PublishMoodParams.model_validate(kw, from_attributes=True))
|
|
281
316
|
)
|
|
282
317
|
|
|
283
318
|
async def upload_pic(
|
|
@@ -288,6 +323,11 @@ class QzoneH5API:
|
|
|
288
323
|
quality: t.Union[int, float] = 70,
|
|
289
324
|
) -> UploadPicResponse:
|
|
290
325
|
"""
|
|
326
|
+
:param picture: image data or path
|
|
327
|
+
:param width: image width, if provided with height, the image will not be compressed
|
|
328
|
+
:param height: image height, if provided with width, the image will not be compressed
|
|
329
|
+
:param quality: compression quality, default to 70
|
|
330
|
+
|
|
291
331
|
.. versionchanged:: 1.8.5
|
|
292
332
|
|
|
293
333
|
In version <= 1.8.4, user is responsible for compressing a image and this api
|
|
@@ -324,6 +364,8 @@ class QzoneH5API:
|
|
|
324
364
|
"""Preupload photos before publishing a feed.
|
|
325
365
|
|
|
326
366
|
:param upload_pics: List of :obj:`.UploadPicResponse`, usually returned by :meth:`.upload_pic`
|
|
367
|
+
:param cur_num: current photo number, default to 0
|
|
368
|
+
:param upload_hd: whether to upload HD version, default to False
|
|
327
369
|
|
|
328
370
|
.. seealso:: :meth:`.upload_pic`
|
|
329
371
|
"""
|
|
@@ -42,6 +42,12 @@ class ConstLoginMan(Loginable):
|
|
|
42
42
|
*,
|
|
43
43
|
ch_login_notify: t.Optional[FutureStore] = None,
|
|
44
44
|
) -> None:
|
|
45
|
+
"""Initialize with a pre-existing cookie dict.
|
|
46
|
+
|
|
47
|
+
:param uin: QQ number
|
|
48
|
+
:param cookie: pre-existing cookie dict
|
|
49
|
+
:param ch_login_notify: whether to enable login event emission
|
|
50
|
+
"""
|
|
45
51
|
super().__init__(uin, ch_login_notify=ch_login_notify)
|
|
46
52
|
self.cookie = {} if cookie is None else cookie
|
|
47
53
|
|
|
@@ -63,17 +69,30 @@ class ConstLoginMan(Loginable):
|
|
|
63
69
|
|
|
64
70
|
|
|
65
71
|
class UpLoginManager(Loginable):
|
|
72
|
+
"""Login manager using uin-password authentication.
|
|
73
|
+
|
|
74
|
+
Wraps :class:`~qqqr.up.UpWebLogin` (or :class:`~qqqr.up.UpH5Login`)
|
|
75
|
+
and provides cookie refresh, captcha/SMS handling via hooks.
|
|
76
|
+
"""
|
|
77
|
+
|
|
66
78
|
def __init__(
|
|
67
79
|
self,
|
|
68
80
|
client: ClientAdapter,
|
|
69
81
|
config: UpLoginConfig,
|
|
70
82
|
*,
|
|
71
|
-
h5=True,
|
|
83
|
+
h5: bool = True,
|
|
72
84
|
ch_login_notify: t.Optional[FutureStore] = None,
|
|
73
85
|
) -> None:
|
|
86
|
+
"""Initialize UP (uin-password) login manager.
|
|
87
|
+
|
|
88
|
+
:param client: HTTP client
|
|
89
|
+
:param config: UP login config including uin and password
|
|
90
|
+
:param h5: use H5 endpoint (default True)
|
|
91
|
+
:param ch_login_notify: enable login event emission
|
|
92
|
+
"""
|
|
74
93
|
super().__init__(config.uin, ch_login_notify=ch_login_notify)
|
|
75
|
-
self.client = client
|
|
76
|
-
self.config = config
|
|
94
|
+
self.client = client #: HTTP client
|
|
95
|
+
self.config = config #: Login config instance
|
|
77
96
|
self.h5(h5, clear_cookie=False) # init uplogin
|
|
78
97
|
|
|
79
98
|
@retry(
|
|
@@ -101,6 +120,7 @@ class UpLoginManager(Loginable):
|
|
|
101
120
|
raise TryAgain from e
|
|
102
121
|
raise
|
|
103
122
|
except NotImplementedError:
|
|
123
|
+
# intended: forwarded as NeedSmsVerify
|
|
104
124
|
from qqqr.constant import StatusCode
|
|
105
125
|
|
|
106
126
|
raise TencentLoginError(StatusCode.NeedSmsVerify, "需要手机验证")
|
|
@@ -108,7 +128,7 @@ class UpLoginManager(Loginable):
|
|
|
108
128
|
log.warning(f"密码登录:{type(e).__name__},重试", exc_info=True)
|
|
109
129
|
log.debug(e.args, extra=e.__dict__)
|
|
110
130
|
raise TryAgain from e
|
|
111
|
-
except
|
|
131
|
+
except Exception as e:
|
|
112
132
|
log.fatal("密码登录异常", exc_info=True)
|
|
113
133
|
raise UnexpectedLoginError from e
|
|
114
134
|
|
|
@@ -142,17 +162,30 @@ class UpLoginManager(Loginable):
|
|
|
142
162
|
|
|
143
163
|
|
|
144
164
|
class QrLoginManager(Loginable):
|
|
165
|
+
"""Login manager using QR code authentication.
|
|
166
|
+
|
|
167
|
+
Wraps :class:`~qqqr.qr.QrLogin` and emits QR image via the
|
|
168
|
+
``qr_fetched`` hook for downstream display.
|
|
169
|
+
"""
|
|
170
|
+
|
|
145
171
|
def __init__(
|
|
146
172
|
self,
|
|
147
173
|
client: ClientAdapter,
|
|
148
174
|
config: QrLoginConfig,
|
|
149
175
|
*,
|
|
150
|
-
h5=True,
|
|
176
|
+
h5: bool = True,
|
|
151
177
|
ch_login_notify: t.Optional[FutureStore] = None,
|
|
152
178
|
) -> None:
|
|
179
|
+
"""Initialize QR code login manager.
|
|
180
|
+
|
|
181
|
+
:param client: HTTP client
|
|
182
|
+
:param config: QR login config including uin
|
|
183
|
+
:param h5: use H5 endpoint (default True)
|
|
184
|
+
:param ch_login_notify: enable login event emission
|
|
185
|
+
"""
|
|
153
186
|
super().__init__(config.uin, ch_login_notify=ch_login_notify)
|
|
154
|
-
self.client = client
|
|
155
|
-
self.config = config
|
|
187
|
+
self.client = client #: HTTP client
|
|
188
|
+
self.config = config #: Login config instance
|
|
156
189
|
self.h5(h5, clear_cookie=False) # init uplogin
|
|
157
190
|
|
|
158
191
|
async def _new_cookie(self) -> t.Dict[str, str]:
|
|
@@ -178,8 +211,8 @@ class QrLoginManager(Loginable):
|
|
|
178
211
|
except (GeneratorExit, ClientError) as e:
|
|
179
212
|
log.warning(f"二维码登录:{type(e).__name__},重试", exc_info=True)
|
|
180
213
|
log.debug(e.args, extra=e.__dict__)
|
|
181
|
-
raise TryAgain
|
|
182
|
-
except
|
|
214
|
+
raise TryAgain from e
|
|
215
|
+
except Exception as e:
|
|
183
216
|
log.fatal("二维码登录异常", exc_info=True)
|
|
184
217
|
raise UnexpectedLoginError from e
|
|
185
218
|
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
"""Abstract login manager base.
|
|
2
|
+
|
|
3
|
+
Defines :class:`Loginable` ABC — the interface all login managers
|
|
4
|
+
implement. Provides cookie caching, gtk computation, and login event
|
|
5
|
+
emission.
|
|
6
|
+
"""
|
|
7
|
+
|
|
1
8
|
import asyncio
|
|
2
9
|
from abc import ABC, abstractmethod
|
|
3
10
|
from time import time
|
|
@@ -10,7 +17,17 @@ from qqqr.utils.encrypt import gtk
|
|
|
10
17
|
|
|
11
18
|
|
|
12
19
|
class Loginable(ABC):
|
|
13
|
-
"""Abstract
|
|
20
|
+
"""Abstract login manager interface.
|
|
21
|
+
|
|
22
|
+
Subclasses implement :meth:`_new_cookie` to provide the actual login
|
|
23
|
+
flow (QR, UP, or static cookie). This base class handles cookie
|
|
24
|
+
caching, gtk computation, and login event emission.
|
|
25
|
+
|
|
26
|
+
.. seealso::
|
|
27
|
+
:class:`~aioqzone.api.login.ConstLoginMan`,
|
|
28
|
+
:class:`~aioqzone.api.login.UpLoginManager`,
|
|
29
|
+
:class:`~aioqzone.api.login.QrLoginManager`
|
|
30
|
+
"""
|
|
14
31
|
|
|
15
32
|
last_login: float = 0
|
|
16
33
|
"""Last login time stamp. 0 represents no login since created."""
|
|
@@ -21,13 +38,15 @@ class Loginable(ABC):
|
|
|
21
38
|
|
|
22
39
|
def __init__(self, uin: int, ch_login_notify: Optional[FutureStore] = None) -> None:
|
|
23
40
|
super().__init__()
|
|
24
|
-
self.uin = uin
|
|
41
|
+
self.uin = uin #: Login QQ number
|
|
25
42
|
self.cookie: Dict[str, str] = {}
|
|
26
|
-
self.lock = asyncio.Lock()
|
|
43
|
+
self.lock = asyncio.Lock() #: Lock to serialize concurrent cookie refresh attempts
|
|
27
44
|
self.ch_login_notify = ch_login_notify or FutureStore()
|
|
28
45
|
|
|
29
46
|
self.login_success = MT.login_success()
|
|
47
|
+
"""Emitted after a successful login."""
|
|
30
48
|
self.login_failed = MT.login_failed()
|
|
49
|
+
"""Emitted after a failed login attempt."""
|
|
31
50
|
|
|
32
51
|
@abstractmethod
|
|
33
52
|
async def _new_cookie(self) -> Dict[str, str]:
|
|
@@ -46,7 +65,7 @@ class Loginable(ABC):
|
|
|
46
65
|
an actual login at the same time, other requests will block until the first is complete
|
|
47
66
|
and share the cookie from this single login.
|
|
48
67
|
|
|
49
|
-
:return:
|
|
68
|
+
:return: True if login succeeded, False otherwise.
|
|
50
69
|
"""
|
|
51
70
|
if self.lock.locked():
|
|
52
71
|
last_gtk = self.gtk
|
|
@@ -57,7 +76,7 @@ class Loginable(ABC):
|
|
|
57
76
|
async with self.lock:
|
|
58
77
|
try:
|
|
59
78
|
self.cookie = await self._new_cookie()
|
|
60
|
-
except
|
|
79
|
+
except Exception as e:
|
|
61
80
|
self.ch_login_notify.add_awaitable(self.login_failed.emit(self.uin, e))
|
|
62
81
|
return False
|
|
63
82
|
else:
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
"""aioqzone exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Defines :class:`QzoneError` (HTTP OK but Qzone returned an error code),
|
|
4
|
+
:class:`UnexpectedLoginError`, and :class:`CorruptError`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
1
8
|
class QzoneError(RuntimeError):
|
|
2
9
|
"""HTTP OK, but Qzone returns an error code."""
|
|
3
10
|
|
|
@@ -8,7 +15,7 @@ class QzoneError(RuntimeError):
|
|
|
8
15
|
self.robj = robj
|
|
9
16
|
if len(args) > 0 and isinstance(args[0], str):
|
|
10
17
|
self.msg = args[0]
|
|
11
|
-
super().__init__(
|
|
18
|
+
super().__init__(*args)
|
|
12
19
|
|
|
13
20
|
def __str__(self) -> str:
|
|
14
21
|
return f"QzoneCode {self.code}: {self.msg}"
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
"""Qzone API endpoint definitions.
|
|
2
|
+
|
|
3
|
+
Each class in this module defines a Qzone H5 API endpoint — its URL,
|
|
4
|
+
HTTP method, request params type, and response type. The :class:`QzoneApi`
|
|
5
|
+
base class provides common infrastructure; concrete subclasses wire up
|
|
6
|
+
specific endpoints.
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
import typing as t
|
|
2
10
|
|
|
3
11
|
from pydantic import BaseModel, Field
|
|
@@ -33,6 +41,8 @@ class QzoneApi(BaseModel, t.Generic[TyRequest, TyResponse]):
|
|
|
33
41
|
|
|
34
42
|
|
|
35
43
|
class IndexPageApi(QzoneApi[QzoneRequestParams, IndexPageResp]):
|
|
44
|
+
"""H5 Qzone landing page API. GET ``/mqzone/index``. Returns :class:`IndexPageResp` with qzonetoken."""
|
|
45
|
+
|
|
36
46
|
response: t.ClassVar = IndexPageResp
|
|
37
47
|
http_method: t.ClassVar[TyHttpMethod] = "GET"
|
|
38
48
|
path: t.ClassVar[str] = "/mqzone/index"
|
|
@@ -41,6 +51,8 @@ class IndexPageApi(QzoneApi[QzoneRequestParams, IndexPageResp]):
|
|
|
41
51
|
|
|
42
52
|
|
|
43
53
|
class UserProfileApi(QzoneApi[ProfileParams, ProfilePagePesp]):
|
|
54
|
+
"""User profile page API. GET ``/mqzone/profile``. Returns :class:`ProfilePagePesp`."""
|
|
55
|
+
|
|
44
56
|
response: t.ClassVar = ProfilePagePesp
|
|
45
57
|
http_method: t.ClassVar[TyHttpMethod] = "GET"
|
|
46
58
|
path: t.ClassVar[str] = "/mqzone/profile"
|
|
@@ -49,18 +61,24 @@ class UserProfileApi(QzoneApi[ProfileParams, ProfilePagePesp]):
|
|
|
49
61
|
|
|
50
62
|
|
|
51
63
|
class FeedPageApi(QzoneApi[ActiveFeedsParams, FeedPageResp]):
|
|
64
|
+
"""Active feeds pagination API. GET ``/mqzone_feeds/getActiveFeeds``. Returns :class:`FeedPageResp`."""
|
|
65
|
+
|
|
52
66
|
response: t.ClassVar = FeedPageResp
|
|
53
67
|
http_method: t.ClassVar[TyHttpMethod] = "GET"
|
|
54
68
|
path: t.ClassVar[str] = "/webapp/json/mqzone_feeds/getActiveFeeds"
|
|
55
69
|
|
|
56
70
|
|
|
57
71
|
class ShuoshuoApi(QzoneApi[ShuoshuoParams, DetailResp]):
|
|
72
|
+
"""Feed detail (shuoshuo) API. GET ``/mqzone_detail/shuoshuo``. Returns :class:`DetailResp`."""
|
|
73
|
+
|
|
58
74
|
response: t.ClassVar = DetailResp
|
|
59
75
|
http_method: t.ClassVar[TyHttpMethod] = "GET"
|
|
60
76
|
path: t.ClassVar[str] = "/webapp/json/mqzone_detail/shuoshuo"
|
|
61
77
|
|
|
62
78
|
|
|
63
79
|
class GetFeedsApi(QzoneApi[GetFeedsParams, ProfileResp]):
|
|
80
|
+
"""User feeds pagination API. GET ``/get_feeds``. Returns :class:`ProfileResp`."""
|
|
81
|
+
|
|
64
82
|
response: t.ClassVar = ProfileResp
|
|
65
83
|
http_method: t.ClassVar[TyHttpMethod] = "GET"
|
|
66
84
|
host: t.ClassVar[str] = "https://mobile.qzone.qq.com"
|
|
@@ -68,6 +86,8 @@ class GetFeedsApi(QzoneApi[GetFeedsParams, ProfileResp]):
|
|
|
68
86
|
|
|
69
87
|
|
|
70
88
|
class GetCountApi(QzoneApi[GetCountParams, FeedCount]):
|
|
89
|
+
"""New feeds count / keep-alive API. GET ``/feeds/mfeeds_get_count``. Returns :class:`FeedCount`."""
|
|
90
|
+
|
|
71
91
|
response: t.ClassVar = FeedCount
|
|
72
92
|
params: GetCountParams = Field(default_factory=GetCountParams)
|
|
73
93
|
|
|
@@ -78,16 +98,22 @@ class GetCountApi(QzoneApi[GetCountParams, FeedCount]):
|
|
|
78
98
|
|
|
79
99
|
|
|
80
100
|
class LikeApi(QzoneApi[DolikeParam, SingleReturnResp]):
|
|
101
|
+
"""Like a feed. POST ``/cgi-bin/likes/internal_dolike_app``. Returns :class:`SingleReturnResp`."""
|
|
102
|
+
|
|
81
103
|
response: t.ClassVar = SingleReturnResp
|
|
82
104
|
http_method: t.ClassVar[TyHttpMethod] = "POST"
|
|
83
105
|
path: t.ClassVar[str] = "/proxy/domain/w.qzone.qq.com/cgi-bin/likes/internal_dolike_app"
|
|
84
106
|
|
|
85
107
|
|
|
86
108
|
class UnlikeApi(LikeApi):
|
|
109
|
+
"""Unlike a feed. POST ``/cgi-bin/likes/internal_unlike_app``. Extends :class:`LikeApi`."""
|
|
110
|
+
|
|
87
111
|
path: t.ClassVar[str] = "/proxy/domain/w.qzone.qq.com/cgi-bin/likes/internal_unlike_app"
|
|
88
112
|
|
|
89
113
|
|
|
90
114
|
class AddCommentApi(QzoneApi[AddCommentParams, AddCommentResp]):
|
|
115
|
+
"""Add comment (JSON API). POST ``/qzoneOperation/addComment``. Returns :class:`AddCommentResp`."""
|
|
116
|
+
|
|
91
117
|
response: t.ClassVar = AddCommentResp
|
|
92
118
|
http_method: t.ClassVar[TyHttpMethod] = "POST"
|
|
93
119
|
path: t.ClassVar[str] = "/webapp/json/qzoneOperation/addComment"
|
|
@@ -95,24 +121,32 @@ class AddCommentApi(QzoneApi[AddCommentParams, AddCommentResp]):
|
|
|
95
121
|
|
|
96
122
|
|
|
97
123
|
class AddCommentApiLegacy(QzoneApi[AddCommentParamsLegacy, AddCommentLegacyResp]):
|
|
124
|
+
"""Add comment with photos (legacy API). POST ``/cgi-bin/emotion_cgi_re_feeds``. Returns :class:`AddCommentLegacyResp`."""
|
|
125
|
+
|
|
98
126
|
response: t.ClassVar = AddCommentLegacyResp
|
|
99
127
|
http_method: t.ClassVar[TyHttpMethod] = "POST"
|
|
100
128
|
path: t.ClassVar[str] = "/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_re_feeds"
|
|
101
129
|
|
|
102
130
|
|
|
103
131
|
class DeleteCommentApi(QzoneApi[DeleteCommentParams, DeleteCommentResp]):
|
|
132
|
+
"""Delete a comment. POST ``/cgi-bin/emotion_cgi_delcomment_ugc``. Returns :class:`DeleteCommentResp`."""
|
|
133
|
+
|
|
104
134
|
response: t.ClassVar = DeleteCommentResp
|
|
105
135
|
http_method: t.ClassVar[TyHttpMethod] = "POST"
|
|
106
136
|
path: t.ClassVar[str] = "/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_delcomment_ugc"
|
|
107
137
|
|
|
108
138
|
|
|
109
139
|
class ListFriendApi(QzoneApi):
|
|
140
|
+
"""List friends (reserved, not yet wired). GET ``/friend/mfriend_list``."""
|
|
141
|
+
|
|
110
142
|
http_method: t.ClassVar[TyHttpMethod] = "GET"
|
|
111
143
|
host: t.ClassVar[str] = "https://mobile.qzone.qq.com"
|
|
112
144
|
path: t.ClassVar[str] = "/friend/mfriend_list"
|
|
113
145
|
|
|
114
146
|
|
|
115
147
|
class PublishMoodApi(QzoneApi[PublishMoodParams, PublishMoodResp]):
|
|
148
|
+
"""Publish a mood feed. POST ``/mood/publish_mood``. Returns :class:`PublishMoodResp`."""
|
|
149
|
+
|
|
116
150
|
response: t.ClassVar = PublishMoodResp
|
|
117
151
|
http_method: t.ClassVar[TyHttpMethod] = "POST"
|
|
118
152
|
host: t.ClassVar[str] = "https://mobile.qzone.qq.com"
|
|
@@ -120,6 +154,8 @@ class PublishMoodApi(QzoneApi[PublishMoodParams, PublishMoodResp]):
|
|
|
120
154
|
|
|
121
155
|
|
|
122
156
|
class AddOperationApi(QzoneApi):
|
|
157
|
+
"""Generic operation API. POST ``/operation/operation_add``."""
|
|
158
|
+
|
|
123
159
|
response: t.Type[QzoneResponse]
|
|
124
160
|
http_method: t.ClassVar[TyHttpMethod] = "POST"
|
|
125
161
|
host: t.ClassVar[str] = "https://mobile.qzone.qq.com"
|
|
@@ -127,6 +163,8 @@ class AddOperationApi(QzoneApi):
|
|
|
127
163
|
|
|
128
164
|
|
|
129
165
|
class UploadPicApi(QzoneApi[UploadPicParams, UploadPicResponse]):
|
|
166
|
+
"""Upload image. POST ``/cgi-bin/upload/cgi_upload_pic_v2``. Returns :class:`UploadPicResponse`."""
|
|
167
|
+
|
|
130
168
|
response: t.ClassVar = UploadPicResponse
|
|
131
169
|
http_method: t.ClassVar[TyHttpMethod] = "POST"
|
|
132
170
|
host: t.ClassVar[str] = "https://mobile.qzone.qq.com"
|
|
@@ -134,6 +172,8 @@ class UploadPicApi(QzoneApi[UploadPicParams, UploadPicResponse]):
|
|
|
134
172
|
|
|
135
173
|
|
|
136
174
|
class PhotosPreuploadApi(QzoneApi[PhotosPreuploadParams, PhotosPreuploadResponse]):
|
|
175
|
+
"""Preupload photos before publishing. POST ``/cgi-bin/upload/cgi_upload_pic_v2``. Returns :class:`PhotosPreuploadResponse`."""
|
|
176
|
+
|
|
137
177
|
response: t.ClassVar = PhotosPreuploadResponse
|
|
138
178
|
http_method: t.ClassVar[TyHttpMethod] = "POST"
|
|
139
179
|
host: t.ClassVar[str] = "https://mobile.qzone.qq.com"
|
|
@@ -141,6 +181,8 @@ class PhotosPreuploadApi(QzoneApi[PhotosPreuploadParams, PhotosPreuploadResponse
|
|
|
141
181
|
|
|
142
182
|
|
|
143
183
|
class AvatarApi(QzoneApi[AvatarParams, AvatarResponse]):
|
|
184
|
+
"""Get avatar by uin (no login required). GET ``/qzone/{hostuin}/{hostuin}/{size}``. Returns :class:`AvatarResponse`."""
|
|
185
|
+
|
|
144
186
|
response: t.ClassVar = AvatarResponse
|
|
145
187
|
login_required: t.ClassVar[bool] = False
|
|
146
188
|
http_method: t.ClassVar[TyHttpMethod] = "GET"
|
|
@@ -149,6 +191,8 @@ class AvatarApi(QzoneApi[AvatarParams, AvatarResponse]):
|
|
|
149
191
|
|
|
150
192
|
|
|
151
193
|
class SetTopApi(QzoneApi[SetTopParams, SingleReturnResp]):
|
|
194
|
+
"""Set/unset feed as top. POST ``/cgi-bin/feeds/cgi_settopfeed``. Returns :class:`SingleReturnResp`."""
|
|
195
|
+
|
|
152
196
|
response: t.ClassVar = SingleReturnResp
|
|
153
197
|
http_method: t.ClassVar[TyHttpMethod] = "POST"
|
|
154
198
|
host: t.ClassVar[str] = "user.qzone.qq.com"
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
"""Feed data models.
|
|
2
|
+
|
|
3
|
+
Defines the core feed data structures including :class:`FeedData`,
|
|
4
|
+
:class:`FeedCommon`, :class:`UserInfo`, feed media types, and related
|
|
5
|
+
enums. These models represent the Qzone feed JSON structure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
1
8
|
import typing as t
|
|
2
9
|
from enum import IntEnum
|
|
3
10
|
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
"""Qzone API request parameter models.
|
|
2
|
+
|
|
3
|
+
Defines :class:`QzoneRequestParams` and its subclasses for each API
|
|
4
|
+
endpoint. Each subclass carries fields specific to that endpoint and
|
|
5
|
+
provides :meth:`build_params` for serialization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
1
8
|
import typing as t
|
|
2
9
|
from base64 import b64encode
|
|
3
10
|
from io import BytesIO
|
|
@@ -190,6 +197,11 @@ class PublishMoodParams(QzoneRequestParams):
|
|
|
190
197
|
)
|
|
191
198
|
ugc_right: UgcRight = UgcRight.all
|
|
192
199
|
|
|
200
|
+
allow_uins: t.List[int] = Field(default_factory=list)
|
|
201
|
+
"""When ugc_right=part (16), only these QQ users can see this shuoshuo."""
|
|
202
|
+
deny_uins: t.List[int] = Field(default_factory=list)
|
|
203
|
+
"""When ugc_right=blacklist (128), these QQ users cannot see this shuoshuo."""
|
|
204
|
+
|
|
193
205
|
opr_type: str = "publish_shuoshuo"
|
|
194
206
|
format: str = "json"
|
|
195
207
|
# lat: int
|
|
@@ -200,6 +212,10 @@ class PublishMoodParams(QzoneRequestParams):
|
|
|
200
212
|
def richval(self, photos: t.List[PhotoData]):
|
|
201
213
|
return " ".join(i.to_richval() for i in photos)
|
|
202
214
|
|
|
215
|
+
@field_serializer("allow_uins", "deny_uins")
|
|
216
|
+
def uinlist_to_str(self, uins: t.List[int]) -> str:
|
|
217
|
+
return ",".join(str(u) for u in uins) if uins else ""
|
|
218
|
+
|
|
203
219
|
|
|
204
220
|
class DeleteUgcParams(QzoneRequestParams):
|
|
205
221
|
uin_fields = ("res_uin",)
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
"""Qzone API response models.
|
|
2
|
+
|
|
3
|
+
Defines :class:`QzoneResponse` base class and its subclasses for each
|
|
4
|
+
API endpoint. Includes factory methods :meth:`~QzoneResponse.from_response_object`
|
|
5
|
+
and :meth:`~QzoneResponse.response_to_object` for HTTP response parsing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
1
8
|
import re
|
|
2
9
|
import typing as t
|
|
3
10
|
from contextlib import suppress
|
|
@@ -212,7 +219,8 @@ class ProfilePagePesp(QzoneResponse):
|
|
|
212
219
|
data = script[m.end() - 1 : m.end() + entire_closing(script[m.end() - 1 :], "[")]
|
|
213
220
|
data = re.sub(r",,\]$", "]", data)
|
|
214
221
|
data = json_loads(data)
|
|
215
|
-
|
|
222
|
+
if not isinstance(data, list):
|
|
223
|
+
raise TryAgain("ProfilePageResponse: profile not returned")
|
|
216
224
|
if len(data) < 2:
|
|
217
225
|
raise TryAgain("ProfilePageResponse: profile not returned")
|
|
218
226
|
|
|
@@ -261,7 +269,8 @@ class AddCommentLegacyResp(QzoneResponse):
|
|
|
261
269
|
raise TryAgain("AddCommentLegacyResponse: script tag not found")
|
|
262
270
|
|
|
263
271
|
m = response_callback.search(script)
|
|
264
|
-
|
|
272
|
+
if not m:
|
|
273
|
+
raise TryAgain("AddCommentLegacyResponse: callback not found")
|
|
265
274
|
return validate_strdict(json_loads(m.group(1)))
|
|
266
275
|
|
|
267
276
|
|
|
@@ -280,7 +289,8 @@ class DeleteCommentResp(QzoneResponse):
|
|
|
280
289
|
raise TryAgain("DeleteCommentResponse: script tag not found")
|
|
281
290
|
|
|
282
291
|
m = response_callback.search(script)
|
|
283
|
-
|
|
292
|
+
if not m:
|
|
293
|
+
raise TryAgain("DeleteCommentResponse: callback not found")
|
|
284
294
|
return validate_strdict(json_loads(m.group(1)))
|
|
285
295
|
|
|
286
296
|
|
|
@@ -306,7 +316,8 @@ class UploadPicResponse(QzoneResponse):
|
|
|
306
316
|
@classmethod
|
|
307
317
|
async def response_to_object(cls, response: ClientResponse) -> StrDict:
|
|
308
318
|
m = response_callback.search(await response.text())
|
|
309
|
-
|
|
319
|
+
if not m:
|
|
320
|
+
raise TryAgain("UploadPicResponse: callback not found")
|
|
310
321
|
return validate_strdict(json_loads(m.group(1)))
|
|
311
322
|
|
|
312
323
|
|
|
@@ -331,10 +342,12 @@ class PhotosPreuploadResponse(QzoneResponse):
|
|
|
331
342
|
@classmethod
|
|
332
343
|
async def response_to_object(cls, response: ClientResponse) -> StrDict:
|
|
333
344
|
m = response_callback.search(await response.text())
|
|
334
|
-
|
|
345
|
+
if not m:
|
|
346
|
+
raise TryAgain("PhotosPreuploadResponse: callback not found")
|
|
335
347
|
|
|
336
348
|
picinfos = json_loads(m.group(1))
|
|
337
|
-
|
|
349
|
+
if not isinstance(picinfos, list):
|
|
350
|
+
raise TryAgain("PhotosPreuploadResponse: picinfos not a list")
|
|
338
351
|
return dict(photos=[PicInfo.from_response_object(info["picinfo"]) for info in picinfos]) # type: ignore
|
|
339
352
|
|
|
340
353
|
|