Skip to content

Commit 396f06a

Browse files
authored
feat: add Medal email and username modules (#632)
1 parent 68491a1 commit 396f06a

2 files changed

Lines changed: 180 additions & 0 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import httpx
2+
3+
from user_scanner.core.helpers import get_global_timeout, get_proxy
4+
from user_scanner.core.result import Result
5+
6+
BASE_URL = "https://medal.tv"
7+
8+
9+
async def validate_medal(email: str) -> Result:
10+
try:
11+
async with httpx.AsyncClient(
12+
timeout=get_global_timeout() or 15.0, proxy=get_proxy()
13+
) as client:
14+
response = await client.post(
15+
f"{BASE_URL}/api/users/email",
16+
json={"email": email},
17+
)
18+
except httpx.HTTPError as exc:
19+
return Result.error(exc, url=f"{BASE_URL}/login")
20+
21+
if response.status_code == 429:
22+
return Result.error("Rate limited by Medal", url=f"{BASE_URL}/login")
23+
if response.status_code != 200:
24+
return Result.error(
25+
f"Unexpected response status: {response.status_code}",
26+
url=f"{BASE_URL}/login",
27+
)
28+
29+
try:
30+
data = response.json()
31+
except ValueError:
32+
return Result.error(
33+
"Medal returned a non-JSON email response", url=f"{BASE_URL}/login"
34+
)
35+
36+
if data.get("valid") is not True:
37+
return Result.error("Medal rejected the email", url=f"{BASE_URL}/login")
38+
if data.get("exists") is True:
39+
return Result.taken(url=f"{BASE_URL}/login")
40+
if data.get("exists") is False:
41+
return Result.available(url=f"{BASE_URL}/login")
42+
return Result.error("Unexpected Medal email response", url=f"{BASE_URL}/login")
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import json
2+
import re
3+
from datetime import datetime, timezone
4+
from urllib.parse import quote
5+
6+
import httpx
7+
8+
from user_scanner.core.orchestrator import generic_validate, make_request
9+
from user_scanner.core.result import Result
10+
11+
BASE_URL = "https://medal.tv"
12+
13+
14+
def validate_medal(user: str) -> Result:
15+
profile_url = f"{BASE_URL}/u/{quote(user, safe='')}"
16+
17+
def process(response):
18+
if response.status_code == 429:
19+
return Result.error("Rate limited by Medal")
20+
if response.status_code != 200:
21+
return Result.error(f"Unexpected response status: {response.status_code}")
22+
23+
try:
24+
data = response.json()
25+
except ValueError:
26+
return Result.error("Medal returned a non-JSON username response")
27+
28+
if data.get("valid") is not True:
29+
return Result.error("Medal rejected the username")
30+
if data.get("exists") is False:
31+
return Result.available()
32+
if data.get("exists") is not True:
33+
return Result.error("Unexpected Medal username response")
34+
35+
try:
36+
profile_response = make_request(profile_url, follow_redirects=True)
37+
except httpx.HTTPError:
38+
return Result.taken()
39+
return _profile_result(profile_response, user)
40+
41+
return generic_validate(
42+
f"{BASE_URL}/api/users/username",
43+
process,
44+
method="POST",
45+
json={"username": user},
46+
show_url=profile_url,
47+
)
48+
49+
50+
def _profile_result(response, user: str) -> Result:
51+
match = re.search(
52+
r"<script>var hydrationData=(.*?)</script>", response.text, re.DOTALL
53+
)
54+
if not match:
55+
return Result.taken()
56+
57+
try:
58+
profiles = json.loads(match.group(1)).get("profiles", {})
59+
except (json.JSONDecodeError, AttributeError):
60+
return Result.taken()
61+
62+
profile = next(
63+
(
64+
item
65+
for item in profiles.values()
66+
if isinstance(item, dict)
67+
and str(item.get("userName", "")).lower() == user.lower()
68+
),
69+
None,
70+
)
71+
if profile is None:
72+
return Result.taken()
73+
74+
extra = {
75+
"user_id": profile.get("userId"),
76+
"display_name": profile.get("displayName"),
77+
"bio": profile.get("slogan"),
78+
"followers": profile.get("followers"),
79+
"following": profile.get("following"),
80+
"submissions": profile.get("submissions"),
81+
"upvotes": profile.get("upvotes"),
82+
}
83+
if created_at := _iso_timestamp(profile.get("createdAt")):
84+
extra["created_at"] = created_at
85+
86+
achievement_names = [
87+
achievement["name"]
88+
for achievement in profile.get("achievements") or []
89+
if isinstance(achievement, dict) and isinstance(achievement.get("name"), str)
90+
]
91+
if achievement_names:
92+
extra["achievements"] = ", ".join(achievement_names)
93+
94+
roles = [role for role in profile.get("roles") or [] if isinstance(role, dict)]
95+
if role_names := [role["name"] for role in roles if role.get("name")]:
96+
extra["roles"] = ", ".join(role_names)
97+
if badge_levels := [
98+
role["badgeLevelName"] for role in roles if role.get("badgeLevelName")
99+
]:
100+
extra["badge_levels"] = ", ".join(badge_levels)
101+
102+
if (premium_status := profile.get("premiumType")) not in (None, "", "NONE"):
103+
extra["premium_status"] = premium_status
104+
105+
active_state = profile.get("activeGameState") or {}
106+
active_game = next(
107+
(
108+
context
109+
for context in active_state.get("contexts") or []
110+
if isinstance(context, dict) and context.get("name")
111+
),
112+
None,
113+
)
114+
if active_game:
115+
extra["active_game"] = active_game["name"]
116+
if started_at := _iso_timestamp(active_game.get("startedAt")):
117+
extra["active_game_started_at"] = started_at
118+
119+
for connection in profile.get("connections") or []:
120+
if (
121+
isinstance(connection, dict)
122+
and connection.get("public")
123+
and connection.get("provider")
124+
):
125+
extra[connection["provider"]] = connection.get("username")
126+
extra[f"{connection['provider']}_id"] = connection.get("id")
127+
128+
media = {
129+
"avatar": profile.get("thumbnail"),
130+
"banner": profile.get("animatedCoverPhoto") or profile.get("coverPhoto"),
131+
}
132+
return Result.taken(extra=extra, media=media)
133+
134+
135+
def _iso_timestamp(value) -> str | None:
136+
if not isinstance(value, (int, float)):
137+
return None
138+
return datetime.fromtimestamp(value / 1000, tz=timezone.utc).isoformat()

0 commit comments

Comments
 (0)