cookiecloud-decrypt 0.1.0__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.
- cookiecloud_decrypt/__init__.py +61 -0
- cookiecloud_decrypt/decrypt.py +309 -0
- cookiecloud_decrypt/format.py +222 -0
- cookiecloud_decrypt/models.py +40 -0
- cookiecloud_decrypt-0.1.0.dist-info/METADATA +145 -0
- cookiecloud_decrypt-0.1.0.dist-info/RECORD +8 -0
- cookiecloud_decrypt-0.1.0.dist-info/WHEEL +5 -0
- cookiecloud_decrypt-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cookiecloud_decrypt
|
|
3
|
+
~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
CookieCloud 数据解密 SDK。
|
|
6
|
+
|
|
7
|
+
支持两种加密模式:
|
|
8
|
+
|
|
9
|
+
- **aes-128-cbc-fixed**: 新版 CookieCloud 服务器(MD5(key) + 零 IV)
|
|
10
|
+
- **legacy**: 旧版(MD5 chain + Salted__ header,兼容 pycookiecloud)
|
|
11
|
+
|
|
12
|
+
用法::
|
|
13
|
+
|
|
14
|
+
from cookiecloud_decrypt import decrypt, to_playwright_cookies, to_cookie_str
|
|
15
|
+
|
|
16
|
+
# 解密
|
|
17
|
+
data = decrypt(encrypted="BASE64STRING...", uuid="...", password="...")
|
|
18
|
+
|
|
19
|
+
# Playwright 格式
|
|
20
|
+
cookies = to_playwright_cookies(data, domains=[".example.com"])
|
|
21
|
+
|
|
22
|
+
# "k1=v1; k2=v2" 格式
|
|
23
|
+
cookie_str = to_cookie_str(data, domain="example.com", keys=["a1", "b2"])
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .decrypt import (
|
|
27
|
+
decrypt,
|
|
28
|
+
encrypt_legacy,
|
|
29
|
+
encrypt_aes_128_cbc_fixed,
|
|
30
|
+
DecryptMode,
|
|
31
|
+
CookieCloudDecryptError,
|
|
32
|
+
InvalidKeyError,
|
|
33
|
+
CorruptedDataError,
|
|
34
|
+
)
|
|
35
|
+
from .format import (
|
|
36
|
+
to_cookie_dict,
|
|
37
|
+
to_playwright_cookies,
|
|
38
|
+
to_cookie_str,
|
|
39
|
+
)
|
|
40
|
+
from .models import (
|
|
41
|
+
CookieEntry,
|
|
42
|
+
CookieCloudData,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# 解密
|
|
47
|
+
"decrypt",
|
|
48
|
+
"encrypt_legacy",
|
|
49
|
+
"encrypt_aes_128_cbc_fixed",
|
|
50
|
+
"DecryptMode",
|
|
51
|
+
"CookieCloudDecryptError",
|
|
52
|
+
"InvalidKeyError",
|
|
53
|
+
"CorruptedDataError",
|
|
54
|
+
# 格式化
|
|
55
|
+
"to_cookie_dict",
|
|
56
|
+
"to_playwright_cookies",
|
|
57
|
+
"to_cookie_str",
|
|
58
|
+
# 类型
|
|
59
|
+
"CookieEntry",
|
|
60
|
+
"CookieCloudData",
|
|
61
|
+
]
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CookieCloud 数据解密核心实现。
|
|
3
|
+
|
|
4
|
+
支持两种加密模式:
|
|
5
|
+
- aes-128-cbc-fixed: 新版 CookieCloud 服务器(MD5(key) + 零 IV)
|
|
6
|
+
- legacy: 旧版使用(MD5 chain + Salted__ header,兼容 pycookiecloud)
|
|
7
|
+
|
|
8
|
+
加密流程由 CookieCloud 服务端完成,参考:
|
|
9
|
+
https://github.com/easychen/CookieCloud
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from Cryptodome.Cipher import AES
|
|
21
|
+
from Cryptodome.Util.Padding import unpad
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DecryptMode(str, Enum):
|
|
25
|
+
"""解密模式。"""
|
|
26
|
+
LEGACY = "legacy"
|
|
27
|
+
"""旧版模式:MD5 chain + Salted__ header,与 pycookiecloud 兼容。"""
|
|
28
|
+
AES_128_CBC_FIXED = "aes-128-cbc-fixed"
|
|
29
|
+
"""新版模式:MD5(key) + 零 IV。"""
|
|
30
|
+
AUTO = "auto"
|
|
31
|
+
"""自动检测:优先尝试 aes-128-cbc-fixed,再尝试 legacy。"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CookieCloudDecryptError(Exception):
|
|
35
|
+
"""解密失败基类。"""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InvalidKeyError(CookieCloudDecryptError):
|
|
40
|
+
"""密钥错误(密码/UUID 不正确)。"""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CorruptedDataError(CookieCloudDecryptError):
|
|
45
|
+
"""数据损坏(密文格式异常)。"""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# 内部工具
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
SALTED_HEADER = b"Salted__"
|
|
54
|
+
"""OpenSSL salted format header (legacy 模式)。"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _derive_key_md5(data: bytes, salt: bytes, output: int = 48) -> bytes:
|
|
58
|
+
"""
|
|
59
|
+
MD5 chain key derivation — legacy 模式使用。
|
|
60
|
+
等价于 OpenSSL EVP_BytesToKey(MD5)。
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
data: 初始种子(通常是 key + salt)
|
|
64
|
+
salt: 8 字节盐
|
|
65
|
+
output: 需要输出的总字节数
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
key || iv (前 32 字节为 key,后 16 字节为 IV)
|
|
69
|
+
"""
|
|
70
|
+
result = b""
|
|
71
|
+
prev = b""
|
|
72
|
+
while len(result) < output:
|
|
73
|
+
prev = hashlib.md5(prev + data + salt).digest()
|
|
74
|
+
result += prev
|
|
75
|
+
return result[:output]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _derive_key_fixed(key: str) -> bytes:
|
|
79
|
+
"""
|
|
80
|
+
MD5(key) key derivation — aes-128-cbc-fixed 模式使用。
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
key: UUID + '-' + password 的 MD5 十六进制字符串的前 16 字符
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
16 字节密钥
|
|
87
|
+
"""
|
|
88
|
+
return hashlib.md5(key.encode()).digest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# 加密函数(测试用,生产环境不需要)
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def encrypt_legacy(plaintext: str, uuid: str, password: str) -> str:
|
|
96
|
+
"""
|
|
97
|
+
用 legacy 模式加密数据(用于生成测试 fixture)。
|
|
98
|
+
流程与 CookieCloud 服务端一致。
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
plaintext: JSON 字符串
|
|
102
|
+
uuid: CookieCloud UUID
|
|
103
|
+
password: CookieCloud 密码
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
base64 编码后的密文
|
|
107
|
+
"""
|
|
108
|
+
import os
|
|
109
|
+
|
|
110
|
+
key_str = f"{uuid}-{password}"
|
|
111
|
+
hash_hex = hashlib.md5(key_str.encode()).hexdigest()
|
|
112
|
+
key = hash_hex[:16].encode()
|
|
113
|
+
|
|
114
|
+
salt = os.urandom(8)
|
|
115
|
+
key_iv = _derive_key_md5(key, salt, output=48)
|
|
116
|
+
aes_key, aes_iv = key_iv[:32], key_iv[32:]
|
|
117
|
+
|
|
118
|
+
padded = _pad(plaintext.encode("utf-8"))
|
|
119
|
+
cipher = AES.new(aes_key, AES.MODE_CBC, aes_iv)
|
|
120
|
+
ciphertext = cipher.encrypt(padded)
|
|
121
|
+
|
|
122
|
+
return base64.b64encode(b"Salted__" + salt + ciphertext).decode()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def encrypt_aes_128_cbc_fixed(plaintext: str, uuid: str, password: str) -> str:
|
|
126
|
+
"""
|
|
127
|
+
用 aes-128-cbc-fixed 模式加密数据(用于生成测试 fixture)。
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
plaintext: JSON 字符串
|
|
131
|
+
uuid: CookieCloud UUID
|
|
132
|
+
password: CookieCloud 密码
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
base64 编码后的密文
|
|
136
|
+
"""
|
|
137
|
+
import os
|
|
138
|
+
|
|
139
|
+
key_str = f"{uuid}-{password}"
|
|
140
|
+
hash_hex = hashlib.md5(key_str.encode()).hexdigest()
|
|
141
|
+
key = hash_hex[:16].encode()
|
|
142
|
+
|
|
143
|
+
aes_key = _derive_key_fixed(key_str)
|
|
144
|
+
zero_iv = b"\x00" * 16
|
|
145
|
+
|
|
146
|
+
padded = _pad(plaintext.encode("utf-8"))
|
|
147
|
+
cipher = AES.new(aes_key, AES.MODE_CBC, zero_iv)
|
|
148
|
+
ciphertext = cipher.encrypt(padded)
|
|
149
|
+
|
|
150
|
+
return base64.b64encode(ciphertext).decode()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _pad(data: bytes) -> bytes:
|
|
154
|
+
"""PKCS#7 padding。"""
|
|
155
|
+
block_size = 16
|
|
156
|
+
pad_len = block_size - (len(data) % block_size)
|
|
157
|
+
return data + bytes([pad_len] * pad_len)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# 解密函数
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def _decrypt_legacy(encrypted_b64: str, uuid: str, password: str) -> dict[str, Any]:
|
|
165
|
+
"""
|
|
166
|
+
用 legacy 模式解密。
|
|
167
|
+
与 pycookiecloud PyCryptoJS.py 的 decrypt() 完全兼容。
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
CorruptedDataError: 密文格式损坏(Salted__ header 不匹配)
|
|
171
|
+
InvalidKeyError: 密钥错误(解密后不是合法 JSON)
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
encrypted = base64.b64decode(encrypted_b64)
|
|
175
|
+
except Exception as e:
|
|
176
|
+
raise CorruptedDataError(f"base64 decode failed: {e}") from e
|
|
177
|
+
|
|
178
|
+
if not encrypted.startswith(SALTED_HEADER):
|
|
179
|
+
raise CorruptedDataError(
|
|
180
|
+
f"Missing OpenSSL salted header. Expected {SALTED_HEADER!r}, "
|
|
181
|
+
f"got {encrypted[:8]!r}. Data may use aes-128-cbc-fixed mode instead."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
salt = encrypted[8:16]
|
|
185
|
+
ciphertext = encrypted[16:]
|
|
186
|
+
|
|
187
|
+
key_str = f"{uuid}-{password}"
|
|
188
|
+
hash_hex = hashlib.md5(key_str.encode()).hexdigest()
|
|
189
|
+
key = hash_hex[:16].encode()
|
|
190
|
+
|
|
191
|
+
key_iv = _derive_key_md5(key, salt, output=48)
|
|
192
|
+
aes_key, aes_iv = key_iv[:32], key_iv[32:]
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
cipher = AES.new(aes_key, AES.MODE_CBC, aes_iv)
|
|
196
|
+
padded = cipher.decrypt(ciphertext)
|
|
197
|
+
plaintext = unpad(padded, AES.block_size)
|
|
198
|
+
return json.loads(plaintext.decode("utf-8"))
|
|
199
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
200
|
+
raise InvalidKeyError(
|
|
201
|
+
f"Decryption failed — wrong key or corrupted data. "
|
|
202
|
+
f"Verify uuid and password are correct. Cause: {e}"
|
|
203
|
+
) from e
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _decrypt_aes_128_cbc_fixed(encrypted_b64: str, uuid: str, password: str) -> dict[str, Any]:
|
|
207
|
+
"""
|
|
208
|
+
用 aes-128-cbc-fixed 模式解密。
|
|
209
|
+
新版 CookieCloud 服务器使用此模式。
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
CorruptedDataError: 密文格式损坏(长度不是 16 的倍数)
|
|
213
|
+
InvalidKeyError: 密钥错误(解密后不是合法 JSON)
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
encrypted = base64.b64decode(encrypted_b64)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
raise CorruptedDataError(f"base64 decode failed: {e}") from e
|
|
219
|
+
|
|
220
|
+
if len(encrypted) % 16 != 0:
|
|
221
|
+
raise CorruptedDataError(
|
|
222
|
+
f"Encrypted data length ({len(encrypted)}) is not a multiple of 16. "
|
|
223
|
+
f"Data may use legacy mode instead."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
key_str = f"{uuid}-{password}"
|
|
227
|
+
aes_key = _derive_key_fixed(key_str)
|
|
228
|
+
zero_iv = b"\x00" * 16
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
cipher = AES.new(aes_key, AES.MODE_CBC, zero_iv)
|
|
232
|
+
padded = cipher.decrypt(encrypted)
|
|
233
|
+
plaintext = unpad(padded, AES.block_size)
|
|
234
|
+
return json.loads(plaintext.decode("utf-8"))
|
|
235
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
236
|
+
# ValueError 可能是:1) wrong key(解密内容非合法 PKCS#7),
|
|
237
|
+
# 2) wrong format(legacy 数据用 fixed 模式解密)。
|
|
238
|
+
# 用 Salted__ header 区分:legacy 格式有 header,fixed 没有。
|
|
239
|
+
# 若有 header 说明是 legacy 格式未匹配,转 CorruptedDataError 以便 auto fallback;
|
|
240
|
+
# 否则视为密钥错误。
|
|
241
|
+
if encrypted.startswith(SALTED_HEADER):
|
|
242
|
+
raise CorruptedDataError(
|
|
243
|
+
f"Data has OpenSSL salted header — appears to be legacy format. "
|
|
244
|
+
f"Retry with mode='legacy'."
|
|
245
|
+
) from e
|
|
246
|
+
raise InvalidKeyError(
|
|
247
|
+
f"Decryption failed — wrong key or corrupted data. "
|
|
248
|
+
f"Verify uuid and password are correct. Cause: {e}"
|
|
249
|
+
) from e
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def decrypt(
|
|
253
|
+
encrypted: str,
|
|
254
|
+
uuid: str,
|
|
255
|
+
password: str,
|
|
256
|
+
mode: DecryptMode | str = DecryptMode.AUTO,
|
|
257
|
+
) -> dict[str, Any]:
|
|
258
|
+
"""
|
|
259
|
+
解密 CookieCloud 服务端返回的加密数据。
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
encrypted: CookieCloud GET /get/{uuid} 返回的 ``encrypted`` 字段(base64)
|
|
263
|
+
uuid: CookieCloud UUID
|
|
264
|
+
password: CookieCloud 密码
|
|
265
|
+
mode: 解密模式,默认 auto(自动检测)
|
|
266
|
+
|
|
267
|
+
- ``"auto"``: 优先尝试 aes-128-cbc-fixed,再尝试 legacy
|
|
268
|
+
- ``"legacy"``: 仅使用 legacy 模式(兼容 pycookiecloud)
|
|
269
|
+
- ``"aes-128-cbc-fixed"``: 仅使用新版模式
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
解密后的原始 JSON dict(含 ``cookie_data`` 等顶层字段)
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
CookieCloudDecryptError: 解密失败
|
|
276
|
+
InvalidKeyError: 密钥错误(uuid 或 password 不正确)
|
|
277
|
+
CorruptedDataError: 密文格式损坏
|
|
278
|
+
|
|
279
|
+
Example::
|
|
280
|
+
|
|
281
|
+
from cookiecloud_decrypt import decrypt
|
|
282
|
+
|
|
283
|
+
data = decrypt(
|
|
284
|
+
encrypted="BASE64STRING...",
|
|
285
|
+
uuid="your-uuid",
|
|
286
|
+
password="your-password",
|
|
287
|
+
)
|
|
288
|
+
cookie_dict = data["cookie_data"]
|
|
289
|
+
"""
|
|
290
|
+
if isinstance(mode, str):
|
|
291
|
+
mode = DecryptMode(mode.lower())
|
|
292
|
+
|
|
293
|
+
if mode == DecryptMode.AUTO:
|
|
294
|
+
# 优先尝试 aes-128-cbc-fixed(新版)
|
|
295
|
+
try:
|
|
296
|
+
return _decrypt_aes_128_cbc_fixed(encrypted, uuid, password)
|
|
297
|
+
except CorruptedDataError:
|
|
298
|
+
# 不是 fixed 格式,尝试 legacy
|
|
299
|
+
return _decrypt_legacy(encrypted, uuid, password)
|
|
300
|
+
# InvalidKeyError 直接抛出,不 fallback(密钥错误时 legacy 也会失败)
|
|
301
|
+
|
|
302
|
+
elif mode == DecryptMode.AES_128_CBC_FIXED:
|
|
303
|
+
return _decrypt_aes_128_cbc_fixed(encrypted, uuid, password)
|
|
304
|
+
|
|
305
|
+
elif mode == DecryptMode.LEGACY:
|
|
306
|
+
return _decrypt_legacy(encrypted, uuid, password)
|
|
307
|
+
|
|
308
|
+
else:
|
|
309
|
+
raise ValueError(f"Unknown decrypt mode: {mode}")
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CookieCloud 解密结果的格式化工具。
|
|
3
|
+
支持 dict / Playwright add_cookies / "k1=v1; k2=v2" 三种导出格式。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .models import CookieCloudData, CookieEntry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# 内部工具
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
_COOKIE_PRIORITY_DEFAULT = "Medium"
|
|
18
|
+
"""CookieCloud 默认 priority。"""
|
|
19
|
+
|
|
20
|
+
_SAME_SITE_DEFAULT = "Lax"
|
|
21
|
+
"""Playwright add_cookies 默认 sameSite。"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# 导出函数
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def to_cookie_dict(
|
|
29
|
+
data: CookieCloudData,
|
|
30
|
+
domains: list[str] | None = None,
|
|
31
|
+
*,
|
|
32
|
+
unwrap: bool = True,
|
|
33
|
+
) -> dict[str, list[CookieEntry]]:
|
|
34
|
+
"""
|
|
35
|
+
将解密结果导出为 ``{域名: [CookieEntry, ...]}`` 格式。
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
data: :func:`~cookiecloud_decrypt.decrypt` 的返回值
|
|
39
|
+
domains: 仅导出的域名白名单,默认为全部域名。
|
|
40
|
+
支持精确匹配(如 ``"xiaohongshu.com"``)和通配符
|
|
41
|
+
(如 ``".xiaohongshu.com"``,以点开头表示匹配该域及子域)。
|
|
42
|
+
unwrap: 若为 ``True``(默认),自动去掉 ``data["cookie_data"]`` 外层;
|
|
43
|
+
若为 ``False``,直接使用原始返回值(不含 ``cookie_data`` 包装)。
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
``{域名: [CookieEntry, ...]}``
|
|
47
|
+
|
|
48
|
+
Example::
|
|
49
|
+
|
|
50
|
+
cookie_dict = to_cookie_dict(
|
|
51
|
+
data,
|
|
52
|
+
domains=[".xiaohongshu.com", "xiaohongshu.com"],
|
|
53
|
+
)
|
|
54
|
+
"""
|
|
55
|
+
cookie_data = data if not unwrap else data.get("cookie_data", data)
|
|
56
|
+
|
|
57
|
+
if domains is None:
|
|
58
|
+
return dict(cookie_data)
|
|
59
|
+
|
|
60
|
+
result: dict[str, list[CookieEntry]] = {}
|
|
61
|
+
for domain in cookie_data:
|
|
62
|
+
# 域名匹配
|
|
63
|
+
if not _domain_matches(domain, domains):
|
|
64
|
+
continue
|
|
65
|
+
result[domain] = cookie_data[domain]
|
|
66
|
+
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_playwright_cookies(
|
|
71
|
+
data: CookieCloudData,
|
|
72
|
+
domains: list[str] | None = None,
|
|
73
|
+
*,
|
|
74
|
+
unwrap: bool = True,
|
|
75
|
+
) -> list[dict[str, Any]]:
|
|
76
|
+
"""
|
|
77
|
+
将解密结果导出为 Playwright ``BrowserContext.add_cookies()`` 所需格式。
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
data: :func:`~cookiecloud_decrypt.decrypt` 的返回值
|
|
81
|
+
domains: 仅导出的域名白名单,默认为全部域名
|
|
82
|
+
unwrap: 若为 ``True``(默认),自动去掉 ``data["cookie_data"]`` 外层
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Playwright add_cookies 格式的 cookie 列表::
|
|
86
|
+
|
|
87
|
+
[
|
|
88
|
+
{
|
|
89
|
+
"name": "a1",
|
|
90
|
+
"value": "xxx",
|
|
91
|
+
"domain": "xiaohongshu.com",
|
|
92
|
+
"path": "/",
|
|
93
|
+
"secure": True,
|
|
94
|
+
"httpOnly": False,
|
|
95
|
+
"sameSite": "Lax",
|
|
96
|
+
},
|
|
97
|
+
...
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
Note:
|
|
101
|
+
Playwright add_cookies 要求 domain 不带前导点。
|
|
102
|
+
此函数会自动去掉 ``.xiaohongshu.com`` → ``xiaohongshu.com``。
|
|
103
|
+
|
|
104
|
+
Example::
|
|
105
|
+
|
|
106
|
+
from playwright.async_api import async_playwright
|
|
107
|
+
|
|
108
|
+
cookies = to_playwright_cookies(data, domains=[".xiaohongshu.com"])
|
|
109
|
+
async with async_playwright() as p:
|
|
110
|
+
ctx = await p.chromium.launch()
|
|
111
|
+
await ctx.add_cookies(cookies)
|
|
112
|
+
"""
|
|
113
|
+
cookie_dict = to_cookie_dict(data, domains, unwrap=unwrap)
|
|
114
|
+
|
|
115
|
+
result: list[dict[str, Any]] = []
|
|
116
|
+
for domain, entries in cookie_dict.items():
|
|
117
|
+
# Playwright 要求 domain 不带前导点
|
|
118
|
+
playwright_domain = domain.lstrip(".")
|
|
119
|
+
|
|
120
|
+
for entry in entries:
|
|
121
|
+
name = entry.get("name", "")
|
|
122
|
+
value = entry.get("value", "")
|
|
123
|
+
if not name:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
result.append({
|
|
127
|
+
"name": name,
|
|
128
|
+
"value": value,
|
|
129
|
+
"domain": playwright_domain,
|
|
130
|
+
"path": entry.get("path", "/"),
|
|
131
|
+
"secure": entry.get("secure", True),
|
|
132
|
+
"httpOnly": entry.get("httpOnly", False),
|
|
133
|
+
"sameSite": entry.get("sameSite") or _SAME_SITE_DEFAULT,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def to_cookie_str(
|
|
140
|
+
data: CookieCloudData,
|
|
141
|
+
domain: str,
|
|
142
|
+
keys: list[str] | None = None,
|
|
143
|
+
*,
|
|
144
|
+
unwrap: bool = True,
|
|
145
|
+
separator: str = "; ",
|
|
146
|
+
ends_with_separator: bool = False,
|
|
147
|
+
) -> str:
|
|
148
|
+
"""
|
|
149
|
+
将解密结果导出为 ``"k1=v1; k2=v2"`` 格式的 cookie 字符串。
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
data: :func:`~cookiecloud_decrypt.decrypt` 的返回值
|
|
153
|
+
domain: 目标域名(如 ``"xiaohongshu.com"`` 或 ``".xiaohongshu.com"``)。
|
|
154
|
+
会自动尝试带点和不带点两种形式匹配。
|
|
155
|
+
keys: 仅导出的 cookie 名称白名单,默认为全部。
|
|
156
|
+
unwrap: 若为 ``True``(默认),自动去掉 ``data["cookie_data"]`` 外层
|
|
157
|
+
separator: key=value 对之间的分隔符,默认为 ``"; "``
|
|
158
|
+
ends_with_separator: 若为 ``True``,结果以分隔符结尾
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
cookie 字符串,如 ``"a1=xxx; web_session=yyy"``
|
|
162
|
+
|
|
163
|
+
Example::
|
|
164
|
+
|
|
165
|
+
cookie_str = to_cookie_str(
|
|
166
|
+
data,
|
|
167
|
+
domain=".xiaohongshu.com",
|
|
168
|
+
keys=["a1", "web_session"],
|
|
169
|
+
)
|
|
170
|
+
"""
|
|
171
|
+
cookie_data = data if not unwrap else data.get("cookie_data", data)
|
|
172
|
+
|
|
173
|
+
# 域名匹配
|
|
174
|
+
entries: list[CookieEntry] = []
|
|
175
|
+
for d, entry_list in cookie_data.items():
|
|
176
|
+
if _domain_matches(d, [domain]):
|
|
177
|
+
entries.extend(entry_list)
|
|
178
|
+
break # 匹配到第一个即停止
|
|
179
|
+
|
|
180
|
+
if not entries:
|
|
181
|
+
return ""
|
|
182
|
+
|
|
183
|
+
parts: list[str] = []
|
|
184
|
+
for entry in entries:
|
|
185
|
+
name = entry.get("name", "")
|
|
186
|
+
value = entry.get("value", "")
|
|
187
|
+
if not name:
|
|
188
|
+
continue
|
|
189
|
+
if keys is not None and name not in keys:
|
|
190
|
+
continue
|
|
191
|
+
parts.append(f"{name}={value}")
|
|
192
|
+
|
|
193
|
+
result = separator.join(parts)
|
|
194
|
+
if ends_with_separator and result and not result.endswith(separator):
|
|
195
|
+
result += separator
|
|
196
|
+
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# 内部工具
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _domain_matches(domain: str, patterns: list[str]) -> bool:
|
|
205
|
+
"""
|
|
206
|
+
判断 domain 是否匹配 patterns 中的任意一个。
|
|
207
|
+
|
|
208
|
+
匹配规则:
|
|
209
|
+
- 精确匹配:``"xiaohongshu.com"`` 匹配 ``"xiaohongshu.com"``
|
|
210
|
+
- 带点前缀:``".xiaohongshu.com"`` 匹配 ``"xiaohongshu.com"`` 及所有子域
|
|
211
|
+
- 子域匹配:``"www.xiaohongshu.com"`` 匹配 ``".xiaohongshu.com"``
|
|
212
|
+
"""
|
|
213
|
+
domain_clean = domain.lstrip(".")
|
|
214
|
+
|
|
215
|
+
for pattern in patterns:
|
|
216
|
+
pattern_clean = pattern.lstrip(".")
|
|
217
|
+
if pattern_clean == domain_clean:
|
|
218
|
+
return True
|
|
219
|
+
if pattern.startswith(".") and domain.endswith(pattern_clean):
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
return False
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CookieCloud 数据类型定义。
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, TypedDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CookieEntry(TypedDict, total=False):
|
|
11
|
+
"""CookieCloud 返回的单条 Cookie 条目。"""
|
|
12
|
+
name: str
|
|
13
|
+
value: str
|
|
14
|
+
domain: str
|
|
15
|
+
path: str
|
|
16
|
+
expires: int
|
|
17
|
+
size: int
|
|
18
|
+
httpOnly: bool
|
|
19
|
+
secure: bool
|
|
20
|
+
sameSite: str
|
|
21
|
+
session: bool
|
|
22
|
+
priority: str
|
|
23
|
+
sameParty: bool
|
|
24
|
+
sourceScheme: str
|
|
25
|
+
sourcePort: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# CookieCloud 原始解密结果结构(最外层 JSON)
|
|
29
|
+
CookieCloudData = dict[str, Any]
|
|
30
|
+
"""
|
|
31
|
+
解密后的完整 JSON dict,结构为::
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
"cookie_data": {
|
|
35
|
+
".example.com": [CookieEntry, ...],
|
|
36
|
+
"www.example.com": [CookieEntry, ...],
|
|
37
|
+
},
|
|
38
|
+
"update_time": 1234567890
|
|
39
|
+
}
|
|
40
|
+
"""
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cookiecloud_decrypt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CookieCloud data decryption SDK — supports legacy and aes-128-cbc-fixed modes
|
|
5
|
+
Author-email: cky <cky@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/YOUR_USERNAME/cookiecloud_decrypt
|
|
8
|
+
Project-URL: Repository, https://github.com/YOUR_USERNAME/cookiecloud_decrypt
|
|
9
|
+
Keywords: cookiecloud,cookie,decrypt,web-scraping
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: pycryptodome>=3.19
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-vcr>=1.0; extra == "dev"
|
|
23
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
24
|
+
Requires-Dist: python-dotenv>=1.0; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# cookiecloud_decrypt
|
|
27
|
+
|
|
28
|
+
CookieCloud 数据解密 SDK,纯 Python,无其他运行时依赖。
|
|
29
|
+
|
|
30
|
+
支持两种加密模式:
|
|
31
|
+
|
|
32
|
+
- **aes-128-cbc-fixed**:新版 CookieCloud 服务器(MD5(key) + 零 IV)
|
|
33
|
+
- **legacy**:旧版使用(MD5 chain + Salted__ header,与 pycookiecloud 兼容)
|
|
34
|
+
|
|
35
|
+
## 安装
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install cookiecloud_decrypt
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 快速开始
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from cookiecloud_decrypt import decrypt, to_playwright_cookies, to_cookie_str
|
|
45
|
+
|
|
46
|
+
# 1. 从 CookieCloud 服务端获取加密数据(任意 HTTP 库)
|
|
47
|
+
import httpx
|
|
48
|
+
resp = httpx.get(
|
|
49
|
+
"https://your-server.com/get/YOUR_UUID",
|
|
50
|
+
params={"password": "YOUR_PASSWORD"},
|
|
51
|
+
)
|
|
52
|
+
encrypted = resp.json()["encrypted"]
|
|
53
|
+
|
|
54
|
+
# 2. 解密(auto 模式自动识别格式)
|
|
55
|
+
data = decrypt(encrypted, uuid="YOUR_UUID", password="YOUR_PASSWORD")
|
|
56
|
+
|
|
57
|
+
# 3. 格式化输出
|
|
58
|
+
# Playwright add_cookies 格式
|
|
59
|
+
cookies = to_playwright_cookies(data, domains=[".example.com"])
|
|
60
|
+
|
|
61
|
+
# "k1=v1; k2=v2" 字符串格式
|
|
62
|
+
cookie_str = to_cookie_str(data, domain=".example.com", keys=["a1", "session"])
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## API
|
|
66
|
+
|
|
67
|
+
### `decrypt(encrypted, uuid, password, mode="auto")`
|
|
68
|
+
|
|
69
|
+
解密 CookieCloud 服务端返回的加密数据。
|
|
70
|
+
|
|
71
|
+
| 参数 | 类型 | 说明 |
|
|
72
|
+
|------|------|------|
|
|
73
|
+
| `encrypted` | `str` | CookieCloud GET `/get/{uuid}` 返回的 `encrypted` 字段(base64) |
|
|
74
|
+
| `uuid` | `str` | CookieCloud UUID |
|
|
75
|
+
| `password` | `str` | CookieCloud 密码 |
|
|
76
|
+
| `mode` | `str` | 解密模式:`"auto"`(默认,自动检测)、`"legacy"`、`"aes-128-cbc-fixed"` |
|
|
77
|
+
|
|
78
|
+
**返回值**:解密后的 dict,结构为 `{"cookie_data": {域名: [CookieEntry, ...]}, "update_time": ...}`
|
|
79
|
+
|
|
80
|
+
**异常**:
|
|
81
|
+
- `InvalidKeyError` — UUID 或密码错误
|
|
82
|
+
- `CorruptedDataError` — 密文格式损坏
|
|
83
|
+
|
|
84
|
+
### `to_playwright_cookies(data, domains=None)`
|
|
85
|
+
|
|
86
|
+
导出为 Playwright `BrowserContext.add_cookies()` 所需格式。
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from playwright.async_api import async_playwright
|
|
90
|
+
|
|
91
|
+
cookies = to_playwright_cookies(data, domains=[".xiaohongshu.com"])
|
|
92
|
+
async with async_playwright() as p:
|
|
93
|
+
ctx = await p.chromium.launch()
|
|
94
|
+
await ctx.add_cookies(cookies)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `to_cookie_str(data, domain, keys=None)`
|
|
98
|
+
|
|
99
|
+
导出为 `k1=v1; k2=v2` 格式的字符串。
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
cookie_str = to_cookie_str(
|
|
103
|
+
data,
|
|
104
|
+
domain=".xiaohongshu.com",
|
|
105
|
+
keys=["a1", "web_session"],
|
|
106
|
+
)
|
|
107
|
+
# → "a1=xxx; web_session=yyy"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### `to_cookie_dict(data, domains=None)`
|
|
111
|
+
|
|
112
|
+
导出为 `{域名: [CookieEntry, ...]}` 格式。
|
|
113
|
+
|
|
114
|
+
## 开发
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# 安装
|
|
118
|
+
pip install -e ".[dev]"
|
|
119
|
+
|
|
120
|
+
# 运行测试
|
|
121
|
+
pytest tests/ -v
|
|
122
|
+
|
|
123
|
+
# 端到端测试(首次需要联网录制 cassette)
|
|
124
|
+
cp .env.example .env
|
|
125
|
+
# 填入真实 COOKIECLOUD_SERVER / COOKIECLOUD_UUID / COOKIECLOUD_PASSWORD
|
|
126
|
+
pytest tests/e2e_test.py -v --vcr-record=new_episodes
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## 发布到 PyPI
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# 1. 安装构建工具
|
|
133
|
+
pip install build twine
|
|
134
|
+
|
|
135
|
+
# 2. 构建 wheel 和 sdist
|
|
136
|
+
python -m build
|
|
137
|
+
|
|
138
|
+
# 3. 上传到 TestPyPI(先测)
|
|
139
|
+
twine upload --repository testpypi dist/*
|
|
140
|
+
|
|
141
|
+
# 4. 上传到正式 PyPI
|
|
142
|
+
twine upload dist/*
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
发布后外部用户直接 `pip install cookiecloud_decrypt` 即可使用。
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
cookiecloud_decrypt/__init__.py,sha256=DsNr-sNp0WHuEwk7ybZGFh58do_Euae7rvZ9WwnVp18,1330
|
|
2
|
+
cookiecloud_decrypt/decrypt.py,sha256=IuBsmL6V2vBtDM_W0BNt6qBh0gmFiG7VspTMHYoLIbM,9799
|
|
3
|
+
cookiecloud_decrypt/format.py,sha256=zfFDog6r4AKEzkgPF6PTB6Bz5duEBKVUtTqUo77xLGs,6948
|
|
4
|
+
cookiecloud_decrypt/models.py,sha256=-VIvwDN0RLZ9pG0hscO7nl-jeVwV3SeJ6t9Fbu3x1N0,782
|
|
5
|
+
cookiecloud_decrypt-0.1.0.dist-info/METADATA,sha256=lrSdy1zZFEg-QJmuLB8-9SjLvIsUVSRxn84aYQMokBQ,4080
|
|
6
|
+
cookiecloud_decrypt-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
cookiecloud_decrypt-0.1.0.dist-info/top_level.txt,sha256=vWhyzS-8JH12XXUEkTHtK-AYtpFkSdcpylycM_plLgs,20
|
|
8
|
+
cookiecloud_decrypt-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cookiecloud_decrypt
|