spotapi 1.2.1__tar.gz → 1.2.3__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.
- {spotapi-1.2.1/spotapi.egg-info → spotapi-1.2.3}/PKG-INFO +1 -1
- {spotapi-1.2.1 → spotapi-1.2.3}/setup.py +2 -2
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/client.py +102 -89
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/creator.py +2 -1
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/login.py +3 -2
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/password.py +2 -1
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/user.py +2 -2
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/utils/saver.py +2 -4
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/utils/strings.py +40 -2
- {spotapi-1.2.1 → spotapi-1.2.3/spotapi.egg-info}/PKG-INFO +1 -1
- {spotapi-1.2.1 → spotapi-1.2.3}/LICENSE +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/README.md +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/setup.cfg +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/__init__.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/_tests/__init__.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/_tests/annotations_test.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/album.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/artist.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/exceptions/__init__.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/exceptions/errors.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/family.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/http/__init__.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/http/data.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/http/request.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/player.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/playlist.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/podcast.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/public.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/solvers/__init__.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/solvers/capmonster.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/solvers/capsolver.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/song.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/status.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/types/__init__.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/types/alias.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/types/annotations.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/types/data.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/types/interfaces.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/utils/__init__.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/utils/logger.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi/websocket.py +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi.egg-info/SOURCES.txt +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi.egg-info/dependency_links.txt +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi.egg-info/requires.txt +0 -0
- {spotapi-1.2.1 → spotapi-1.2.3}/spotapi.egg-info/top_level.txt +0 -0
|
@@ -1,59 +1,79 @@
|
|
|
1
|
-
import re
|
|
2
|
-
import hmac
|
|
3
1
|
import time
|
|
2
|
+
import json
|
|
3
|
+
import base64
|
|
4
|
+
import pyotp
|
|
4
5
|
import atexit
|
|
5
|
-
import
|
|
6
|
-
from typing import
|
|
6
|
+
import requests
|
|
7
|
+
from typing import Tuple, Literal
|
|
7
8
|
from collections.abc import Mapping
|
|
9
|
+
from spotapi.utils.logger import Logger
|
|
10
|
+
from spotapi.utils.logger import Logger
|
|
8
11
|
from spotapi.types.annotations import enforce
|
|
9
12
|
from spotapi.types.alias import _UStr, _Undefined
|
|
10
13
|
from spotapi.exceptions import BaseClientError
|
|
11
14
|
from spotapi.http.request import TLSClient
|
|
12
|
-
from spotapi.utils.strings import
|
|
15
|
+
from spotapi.utils.strings import extract_js_links, extract_mappings, combine_chunks
|
|
16
|
+
|
|
17
|
+
# Default recaptcha site key, will update on startup if necessary
|
|
18
|
+
RECAPTCHA_SITE_KEY: str = "6LfCVLAUAAAAALFwwRnnCJ12DalriUGbj8FW_J39"
|
|
19
|
+
# Fallback hardcoded secret (version 18)
|
|
20
|
+
_FALLBACK_SECRET: Tuple[Literal[18], bytearray] = (
|
|
21
|
+
18,
|
|
22
|
+
bytearray(
|
|
23
|
+
[70, 60, 33, 57, 92, 120, 90, 33, 32, 62, 62, 55, 126, 93, 66, 35, 108, 68]
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Cache storage for TOTP
|
|
28
|
+
_secret_cache: Tuple[int, bytearray] | None = None
|
|
29
|
+
_cache_expiry: float = -1
|
|
30
|
+
_CACHE_TTL = 15 * 60
|
|
13
31
|
|
|
14
32
|
__all__ = ["BaseClient", "BaseClientError"]
|
|
15
33
|
|
|
16
|
-
# fmt: off
|
|
17
|
-
# Currently the secret is static but could change at any moment.
|
|
18
|
-
# From the looks of it there is no easy way to get it dynamically per request due to the vaguity of the javascript variables.
|
|
19
|
-
_TOTP_SECRET = bytearray([49, 48, 48, 49, 49, 49, 56, 49, 49, 49, 49, 55, 57, 56, 50, 49, 50, 51, 49, 50, 52, 54, 56, 56, 52, 54, 57, 51, 55, 56, 49,
|
|
20
|
-
51, 50, 54, 52, 52, 50, 56, 49, 57, 57, 52, 55, 57, 50, 51, 54, 53, 51, 53, 57, 49, 49, 51, 54, 52, 49, 48, 54, 50, 50, 49, 51, 49, 48, 55, 51, 48])
|
|
21
|
-
# fmt: on
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def generate_totp(
|
|
25
|
-
secret: bytes = _TOTP_SECRET,
|
|
26
|
-
algorithm: Callable[[], object] = hashlib.sha1,
|
|
27
|
-
digits: int = 6,
|
|
28
|
-
counter_factory: Callable[[], int] = lambda: int(time.time()) // 30,
|
|
29
|
-
) -> Tuple[str, int]:
|
|
30
|
-
counter = counter_factory()
|
|
31
|
-
hmac_result = hmac.new(
|
|
32
|
-
secret, counter.to_bytes(8, byteorder="big"), algorithm # type: ignore
|
|
33
|
-
).digest()
|
|
34
|
-
|
|
35
|
-
offset = hmac_result[-1] & 15
|
|
36
|
-
truncated_value = (
|
|
37
|
-
(hmac_result[offset] & 127) << 24
|
|
38
|
-
| (hmac_result[offset + 1] & 255) << 16
|
|
39
|
-
| (hmac_result[offset + 2] & 255) << 8
|
|
40
|
-
| (hmac_result[offset + 3] & 255)
|
|
41
|
-
)
|
|
42
|
-
return (
|
|
43
|
-
str(truncated_value % (10**digits)).zfill(digits),
|
|
44
|
-
counter * 30_000,
|
|
45
|
-
) # (30 * 1000)
|
|
46
34
|
|
|
35
|
+
def get_latest_totp_secret() -> Tuple[int, bytearray]:
|
|
36
|
+
global _secret_cache, _cache_expiry
|
|
47
37
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
38
|
+
if _secret_cache and time.time() < _cache_expiry:
|
|
39
|
+
return _secret_cache
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
url = "https://github.com/Thereallo1026/spotify-secrets/blob/main/secrets/secretDict.json?raw=true"
|
|
43
|
+
response = requests.get(url, timeout=5)
|
|
44
|
+
if not response.ok:
|
|
45
|
+
raise BaseClientError(f"Failed to fetch secrets: {response.status_code}")
|
|
46
|
+
|
|
47
|
+
secrets = response.json()
|
|
48
|
+
version = max(secrets, key=int)
|
|
49
|
+
secret_list = secrets[version]
|
|
50
|
+
|
|
51
|
+
if not isinstance(secret_list, list):
|
|
52
|
+
raise BaseClientError(
|
|
53
|
+
f"Expected a list of integers, got {type(secret_list)}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_secret_cache = (version, bytearray(secret_list))
|
|
57
|
+
_cache_expiry = time.time() + _CACHE_TTL
|
|
58
|
+
return _secret_cache
|
|
59
|
+
except Exception as e:
|
|
60
|
+
Logger.error(f"Failed to fetch secrets: {e}. Falling back to default secret.")
|
|
61
|
+
return _FALLBACK_SECRET
|
|
53
62
|
|
|
54
|
-
NOTE: Should not be used directly. Use the Spotify classes instead.
|
|
55
|
-
"""
|
|
56
63
|
|
|
64
|
+
def generate_totp() -> Tuple[str, int]:
|
|
65
|
+
version, secret_bytes = get_latest_totp_secret()
|
|
66
|
+
transformed = [e ^ ((t % 33) + 9) for t, e in enumerate(secret_bytes)]
|
|
67
|
+
joined = "".join(str(num) for num in transformed)
|
|
68
|
+
hex_str = joined.encode().hex()
|
|
69
|
+
secret = base64.b32encode(bytes.fromhex(hex_str)).decode().rstrip("=")
|
|
70
|
+
totp = pyotp.TOTP(secret).now()
|
|
71
|
+
return totp, version
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@enforce
|
|
75
|
+
class BaseClient:
|
|
76
|
+
# There are many Javasript packs, but this one contains all the "xpui" packs which contain further packs that contain the hashes we need
|
|
57
77
|
js_pack: _UStr = _Undefined
|
|
58
78
|
client_version: _UStr = _Undefined
|
|
59
79
|
access_token: _UStr = _Undefined
|
|
@@ -87,7 +107,6 @@ class BaseClient:
|
|
|
87
107
|
if "headers" not in kwargs:
|
|
88
108
|
kwargs["headers"] = {}
|
|
89
109
|
|
|
90
|
-
assert self.access_token is not _Undefined, "Access token is _Undefined"
|
|
91
110
|
kwargs["headers"].update(
|
|
92
111
|
{
|
|
93
112
|
"Authorization": "Bearer " + str(self.access_token),
|
|
@@ -100,49 +119,63 @@ class BaseClient:
|
|
|
100
119
|
|
|
101
120
|
def _get_auth_vars(self) -> None:
|
|
102
121
|
if self.access_token is _Undefined or self.client_id is _Undefined:
|
|
103
|
-
totp,
|
|
122
|
+
totp, version = generate_totp()
|
|
123
|
+
totp, version = generate_totp()
|
|
104
124
|
query = {
|
|
105
125
|
"reason": "init",
|
|
106
126
|
"productType": "web-player",
|
|
107
127
|
"totp": totp,
|
|
108
|
-
"totpVer":
|
|
128
|
+
"totpVer": version,
|
|
129
|
+
"totpVer": version,
|
|
109
130
|
"totpServer": totp,
|
|
110
131
|
}
|
|
111
|
-
resp = self.client.get(
|
|
112
|
-
|
|
113
|
-
)
|
|
132
|
+
resp = self.client.get("https://open.spotify.com/api/token", params=query)
|
|
133
|
+
resp = self.client.get("https://open.spotify.com/api/token", params=query)
|
|
114
134
|
|
|
115
135
|
if resp.fail:
|
|
116
|
-
raise BaseClientError(
|
|
117
|
-
"Could not get session auth tokens", error=resp.error.string
|
|
118
|
-
)
|
|
136
|
+
raise BaseClientError("Could not get session auth tokens", error=resp.error.string)
|
|
119
137
|
|
|
120
138
|
self.access_token = resp.response["accessToken"]
|
|
121
139
|
self.client_id = resp.response["clientId"]
|
|
122
140
|
|
|
123
141
|
def get_session(self) -> None:
|
|
124
142
|
resp = self.client.get("https://open.spotify.com")
|
|
125
|
-
|
|
126
143
|
if resp.fail:
|
|
127
144
|
raise BaseClientError("Could not get session", error=resp.error.string)
|
|
128
145
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
146
|
+
_all_js_packs = extract_js_links(resp.response)
|
|
147
|
+
self.js_pack = next(
|
|
148
|
+
(
|
|
149
|
+
link
|
|
150
|
+
for link in _all_js_packs
|
|
151
|
+
if link.startswith(
|
|
152
|
+
"https://open.spotifycdn.com/cdn/build/web-player/web-player"
|
|
153
|
+
)
|
|
154
|
+
and link.endswith(".js")
|
|
155
|
+
),
|
|
156
|
+
"",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
self._raw_app_server_config = resp.response.split(
|
|
160
|
+
'<script id="appServerConfig" type="text/plain">'
|
|
161
|
+
)[1].split("</script>")[0]
|
|
162
|
+
self.server_cfg = json.loads(
|
|
163
|
+
base64.b64decode(self._raw_app_server_config).decode("utf-8")
|
|
164
|
+
)
|
|
135
165
|
|
|
166
|
+
_recaptcha_key = self.server_cfg["recaptchaWebPlayerFraudSiteKey"]
|
|
167
|
+
if _recaptcha_key:
|
|
168
|
+
global RECAPTCHA_SITE_KEY
|
|
169
|
+
RECAPTCHA_SITE_KEY = _recaptcha_key
|
|
170
|
+
|
|
171
|
+
self.client_version = self.server_cfg["clientVersion"]
|
|
136
172
|
self.device_id = self.client.cookies.get("sp_t") or ""
|
|
137
173
|
self._get_auth_vars()
|
|
138
174
|
|
|
139
175
|
def get_client_token(self) -> None:
|
|
140
|
-
if not (self.client_id and self.device_id):
|
|
176
|
+
if not (self.client_id and self.device_id and self.client_version):
|
|
141
177
|
self.get_session()
|
|
142
178
|
|
|
143
|
-
if not self.client_version:
|
|
144
|
-
self.get_sha256_hash()
|
|
145
|
-
|
|
146
179
|
url = "https://clienttoken.spotify.com/v1/clienttoken"
|
|
147
180
|
payload = {
|
|
148
181
|
"client_data": {
|
|
@@ -199,44 +232,24 @@ class BaseClient:
|
|
|
199
232
|
raise ValueError("Could not get playlist hashes")
|
|
200
233
|
|
|
201
234
|
resp = self.client.get(str(self.js_pack))
|
|
202
|
-
|
|
203
235
|
if resp.fail:
|
|
204
236
|
raise BaseClientError(
|
|
205
|
-
"Could not get
|
|
237
|
+
"Could not get general hashes", error=resp.error.string
|
|
206
238
|
)
|
|
207
239
|
|
|
208
|
-
assert isinstance(resp.response, str), "Invalid HTML response"
|
|
209
|
-
|
|
210
240
|
self.raw_hashes = resp.response
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
)[-1]
|
|
217
|
-
self.xpui_route_tracks_num = resp.response.split(':"xpui-routes-track-v2"')[
|
|
218
|
-
0
|
|
219
|
-
].split(",")[-1]
|
|
220
|
-
|
|
221
|
-
xpui_route_pattern = rf'{self.xpui_route_num}:"([^"]*)"'
|
|
222
|
-
self.xpui_route = re.findall(xpui_route_pattern, resp.response)[-1]
|
|
223
|
-
|
|
224
|
-
xpui_route_tracks_pattern = rf'{self.xpui_route_tracks_num}:"([^"]*)"'
|
|
225
|
-
self.xpui_route_tracks = re.findall(xpui_route_tracks_pattern, resp.response)[
|
|
226
|
-
-1
|
|
227
|
-
]
|
|
228
|
-
|
|
229
|
-
urls = (
|
|
230
|
-
f"https://open.spotifycdn.com/cdn/build/web-player/xpui-routes-search.{self.xpui_route}.js",
|
|
231
|
-
f"https://open.spotifycdn.com/cdn/build/web-player/xpui-routes-track-v2.{self.xpui_route_tracks}.js",
|
|
241
|
+
|
|
242
|
+
str_mapping, hash_mapping = extract_mappings(str(self.raw_hashes))
|
|
243
|
+
urls = map(
|
|
244
|
+
lambda s: f"https://open.spotifycdn.com/cdn/build/web-player/{s}",
|
|
245
|
+
combine_chunks(str_mapping, hash_mapping),
|
|
232
246
|
)
|
|
233
247
|
|
|
234
248
|
for url in urls:
|
|
235
249
|
resp = self.client.get(url)
|
|
236
|
-
|
|
237
250
|
if resp.fail:
|
|
238
251
|
raise BaseClientError(
|
|
239
|
-
"Could not get
|
|
252
|
+
"Could not get general hashes", error=resp.error.string
|
|
240
253
|
)
|
|
241
254
|
|
|
242
255
|
self.raw_hashes += resp.response
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import time
|
|
2
2
|
import uuid
|
|
3
3
|
import json
|
|
4
|
+
from spotapi.client import RECAPTCHA_SITE_KEY
|
|
4
5
|
from spotapi.types.annotations import enforce
|
|
5
6
|
from spotapi.types import Config
|
|
6
7
|
from spotapi.exceptions import GeneratorError
|
|
@@ -121,7 +122,7 @@ class Creator:
|
|
|
121
122
|
|
|
122
123
|
captcha_token = self.cfg.solver.solve_captcha(
|
|
123
124
|
"https://www.spotify.com/ca-en/signup",
|
|
124
|
-
|
|
125
|
+
RECAPTCHA_SITE_KEY,
|
|
125
126
|
"website/signup/submit_email",
|
|
126
127
|
"v3",
|
|
127
128
|
)
|
|
@@ -5,6 +5,7 @@ from typing import Any
|
|
|
5
5
|
from collections.abc import Mapping
|
|
6
6
|
from spotapi.types.annotations import enforce
|
|
7
7
|
from urllib.parse import urlencode, quote
|
|
8
|
+
from spotapi.client import RECAPTCHA_SITE_KEY
|
|
8
9
|
from spotapi.types import Config, SaverProtocol
|
|
9
10
|
from spotapi.exceptions import LoginError
|
|
10
11
|
from spotapi.utils.strings import parse_json_string
|
|
@@ -266,8 +267,8 @@ class Login:
|
|
|
266
267
|
raise LoginError("Solver not set")
|
|
267
268
|
|
|
268
269
|
captcha_response = self.solver.solve_captcha(
|
|
269
|
-
"https://accounts.spotify.com
|
|
270
|
-
|
|
270
|
+
"https://accounts.spotify.com",
|
|
271
|
+
RECAPTCHA_SITE_KEY,
|
|
271
272
|
"accounts/login",
|
|
272
273
|
"v3",
|
|
273
274
|
)
|
|
@@ -4,6 +4,7 @@ from spotapi.utils.strings import parse_json_string
|
|
|
4
4
|
from spotapi.types.annotations import enforce
|
|
5
5
|
from spotapi.exceptions import PasswordError
|
|
6
6
|
from spotapi.types.data import Config
|
|
7
|
+
from spotapi.client import RECAPTCHA_SITE_KEY
|
|
7
8
|
|
|
8
9
|
__all__ = ["Password", "PasswordError"]
|
|
9
10
|
|
|
@@ -83,7 +84,7 @@ class Password:
|
|
|
83
84
|
|
|
84
85
|
captcha_response = self.solver.solve_captcha(
|
|
85
86
|
"https://accounts.spotify.com/en/password-reset",
|
|
86
|
-
|
|
87
|
+
RECAPTCHA_SITE_KEY,
|
|
87
88
|
"password_reset_web/recovery",
|
|
88
89
|
"v3",
|
|
89
90
|
)
|
|
@@ -5,7 +5,7 @@ from collections.abc import Mapping
|
|
|
5
5
|
from spotapi.types.annotations import enforce
|
|
6
6
|
from spotapi import utils
|
|
7
7
|
from spotapi.exceptions import UserError
|
|
8
|
-
from spotapi.login import Login
|
|
8
|
+
from spotapi.login import Login, RECAPTCHA_SITE_KEY
|
|
9
9
|
|
|
10
10
|
__all__ = ["User", "UserError"]
|
|
11
11
|
|
|
@@ -93,7 +93,7 @@ class User:
|
|
|
93
93
|
|
|
94
94
|
captcha_response = self.login.solver.solve_captcha(
|
|
95
95
|
"https://www.spotify.com",
|
|
96
|
-
|
|
96
|
+
RECAPTCHA_SITE_KEY,
|
|
97
97
|
"account_settings/profile_update",
|
|
98
98
|
"v3",
|
|
99
99
|
)
|
|
@@ -6,13 +6,11 @@ These are popular savers that are used for session storing, but if you need a di
|
|
|
6
6
|
import atexit
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
|
-
import sqlite3
|
|
10
|
-
from typing import Any, List, Mapping
|
|
11
|
-
|
|
12
9
|
import pymongo
|
|
13
10
|
import redis
|
|
11
|
+
import sqlite3
|
|
12
|
+
from typing import Any, List, Mapping
|
|
14
13
|
from readerwriterlock import rwlock
|
|
15
|
-
|
|
16
14
|
from spotapi.types.interfaces import SaverProtocol
|
|
17
15
|
from spotapi.exceptions import SaverError
|
|
18
16
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
from typing import
|
|
1
|
+
from typing import List, Tuple, Dict
|
|
2
|
+
from bs4 import BeautifulSoup
|
|
2
3
|
import string
|
|
3
4
|
import random
|
|
4
5
|
import base64
|
|
6
|
+
import ast
|
|
5
7
|
import os
|
|
8
|
+
import re
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
__all__ = [
|
|
@@ -17,6 +20,41 @@ __all__ = [
|
|
|
17
20
|
]
|
|
18
21
|
|
|
19
22
|
|
|
23
|
+
def extract_mappings(js_code: str) -> Tuple[Dict[int, str], Dict[int, str]]:
|
|
24
|
+
pattern = r"\{\d+:\"[^\"]+\"(?:,\d+:\"[^\"]+\")*\}"
|
|
25
|
+
matches = re.findall(pattern, js_code)
|
|
26
|
+
|
|
27
|
+
if len(matches) < 2:
|
|
28
|
+
raise ValueError("Could not find both mappings in the JS code.")
|
|
29
|
+
|
|
30
|
+
mapping1 = ast.literal_eval(matches[3])
|
|
31
|
+
mapping2 = ast.literal_eval(matches[4])
|
|
32
|
+
|
|
33
|
+
return mapping1, mapping2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def combine_chunks(name_map: Dict[int, str], hash_map: Dict[int, str]) -> List[str]:
|
|
37
|
+
combined: List[str] = []
|
|
38
|
+
for key in name_map:
|
|
39
|
+
if key in hash_map:
|
|
40
|
+
filename = f"{name_map[key]}.{hash_map[key]}.js"
|
|
41
|
+
combined.append(filename)
|
|
42
|
+
return combined
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def extract_js_links(html_content: str) -> List[str]:
|
|
46
|
+
"""Extracts all JavaScript links from a given HTML content."""
|
|
47
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
48
|
+
js_links = []
|
|
49
|
+
|
|
50
|
+
for script_tag in soup.find_all("script", src=True):
|
|
51
|
+
src = script_tag["src"]
|
|
52
|
+
if src.endswith(".js"):
|
|
53
|
+
js_links.append(str(src))
|
|
54
|
+
|
|
55
|
+
return js_links
|
|
56
|
+
|
|
57
|
+
|
|
20
58
|
def random_b64_string(length: int) -> str:
|
|
21
59
|
"""Used by Spotify internally"""
|
|
22
60
|
|
|
@@ -51,7 +89,7 @@ def parse_json_string(b: str, s: str) -> str:
|
|
|
51
89
|
return b[value_start_index:value_end_index]
|
|
52
90
|
|
|
53
91
|
|
|
54
|
-
def random_string(length: int, /, strong:
|
|
92
|
+
def random_string(length: int, /, strong: bool = False) -> str:
|
|
55
93
|
letters = string.ascii_letters
|
|
56
94
|
rnd = "".join(random.choice(letters) for _ in range(length))
|
|
57
95
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|