Skip to content

Commit 261222f

Browse files
authored
fix: improve Gumroad email and username detection (#627)
- Use signup validation for email checks - Parse profile metadata and verify username matches
1 parent 21fd0b9 commit 261222f

2 files changed

Lines changed: 137 additions & 93 deletions

File tree

Lines changed: 53 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,64 @@
1-
import httpx
21
import re
3-
from user_scanner.core.result import Result
4-
52

6-
async def _check(email: str) -> Result:
7-
show_url = "https://gumroad.com"
8-
async with httpx.AsyncClient(timeout=15.0, http2=False, follow_redirects=True) as client:
9-
try:
10-
url1 = "https://gumroad.com/users/forgot_password/new"
11-
headers1 = {
12-
'User-Agent': "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
13-
'Accept': "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
14-
'Accept-Encoding': "identity",
15-
'sec-ch-ua': '"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"',
16-
'sec-ch-ua-mobile': "?0",
17-
'sec-ch-ua-platform': '"Linux"',
18-
'upgrade-insecure-requests': "1",
19-
'referer': "https://www.google.com/",
20-
'accept-language': "en-US,en;q=0.9"
21-
}
3+
from user_scanner.core.impersonate import impersonate_request_async
4+
from user_scanner.core.result import Result
225

23-
res1 = await client.get(url1, headers=headers1)
24-
html = res1.text
6+
SIGNUP_URL = "https://gumroad.com/signup"
7+
CSRF_RE = re.compile(r'<meta name="csrf-token" content="([^"]+)"')
258

26-
csrf_match = re.search(
27-
r'authenticity_token&quot;:&quot;([^&]+)&quot;', html)
28-
if not csrf_match:
29-
csrf_match = re.search(
30-
r'name="csrf-token" content="([^"]+)"', html)
319

32-
if not csrf_match:
33-
return Result.error("Failed to extract CSRF token")
10+
async def validate_gumroad(email: str) -> Result:
11+
show_url = "https://gumroad.com"
3412

35-
csrf_token = csrf_match.group(1)
13+
try:
14+
page = await impersonate_request_async(SIGNUP_URL, allow_redirects=True)
15+
if page.status_code != 200:
16+
return Result.error(
17+
f"Unexpected Gumroad signup response: {page.status_code}",
18+
url=show_url,
19+
)
3620

37-
url2 = "https://gumroad.com/users/forgot_password"
21+
token = CSRF_RE.search(page.text)
22+
if not token or "Signup/New" not in page.text:
23+
return Result.error("Could not read Gumroad signup form", url=show_url)
3824

39-
payload = {
25+
response = await impersonate_request_async(
26+
SIGNUP_URL,
27+
"POST",
28+
json={
4029
"user": {
41-
"email": email
42-
}
43-
}
30+
"email": email,
31+
# Gumroad rejects this three-character password after checking
32+
# whether the email is already registered.
33+
"password": "Q7~",
34+
},
35+
},
36+
headers={
37+
"accept": "text/html, application/xhtml+xml",
38+
"origin": "https://gumroad.com",
39+
"referer": SIGNUP_URL,
40+
"x-csrf-token": token.group(1),
41+
"x-inertia": "true",
42+
"x-requested-with": "XMLHttpRequest",
43+
},
44+
allow_redirects=True,
45+
)
46+
if response.status_code != 200:
47+
return Result.error(
48+
f"Unexpected Gumroad signup response: {response.status_code}",
49+
url=show_url,
50+
)
4451

45-
headers2 = {
46-
'User-Agent': "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
47-
'Accept': "text/html, application/xhtml+xml",
48-
'Accept-Encoding': "identity",
49-
'Content-Type': "application/json",
50-
'sec-ch-ua-platform': '"Linux"',
51-
'x-csrf-token': csrf_token,
52-
'sec-ch-ua': '"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"',
53-
'x-inertia': "true",
54-
'sec-ch-ua-mobile': "?0",
55-
'x-requested-with': "XMLHttpRequest",
56-
'origin': "https://gumroad.com",
57-
'sec-fetch-site': "same-origin",
58-
'sec-fetch-mode': "cors",
59-
'sec-fetch-dest': "empty",
60-
'referer': "https://gumroad.com/users/forgot_password/new",
61-
'accept-language': "en-US,en;q=0.9",
62-
'priority': "u=1, i"
63-
}
52+
data = response.json()
53+
if data.get("component") != "Signup/New":
54+
return Result.error("Unexpected Gumroad signup page", url=show_url)
6455

65-
response = await client.post(url2, json=payload, headers=headers2)
66-
67-
data = response.json()
68-
flash_msg = data.get("props", {}).get(
69-
"flash", {}).get("message", "")
70-
71-
if "An account does not exist" in flash_msg:
72-
return Result.available(url=show_url)
73-
elif "An account does not exist" not in flash_msg:
74-
return Result.taken(url=show_url)
75-
else:
76-
return Result.error(f"Unexpected status: {response.status_code}")
77-
78-
except Exception as e:
79-
return Result.error(f"unexpected exception: {e}")
80-
81-
82-
async def validate_gumroad(email: str) -> Result:
83-
return await _check(email)
56+
flash = data.get("props", {}).get("flash") or {}
57+
message = flash.get("message")
58+
if message == "An account already exists with this email.":
59+
return Result.taken(url=show_url)
60+
if message == "Password is too short (minimum is 4 characters)":
61+
return Result.available(url=show_url)
62+
return Result.error("Unexpected Gumroad signup result", url=show_url)
63+
except Exception as exc:
64+
return Result.error(exc, url=show_url)
Lines changed: 84 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,91 @@
1+
import html
2+
import json
13
import re
2-
from user_scanner.core.orchestrator import Result, make_request
4+
from typing import cast
5+
6+
from user_scanner.core.orchestrator import generic_validate
7+
from user_scanner.core.result import Result
8+
9+
NOT_FOUND = "<title>Page not found (404) - Gumroad</title>"
10+
11+
12+
def _links(value):
13+
if isinstance(value, dict):
14+
if isinstance(value.get("href"), str):
15+
yield value["href"]
16+
for item in value.values():
17+
yield from _links(item)
18+
elif isinstance(value, list):
19+
for item in value:
20+
yield from _links(item)
321

422

523
def validate_gumroad(user: str) -> Result:
6-
if not re.fullmatch(r"[a-z0-9]{3,20}", user):
24+
username = user.lower()
25+
if not re.fullmatch(r"(?=.*[a-z])[a-z0-9]{3,20}", username):
726
return Result.error(
8-
"Username must be between 3 and 20 lowercase alphanumeric characters"
27+
"Username must be 3-20 lowercase letters and numbers, with at least one letter",
28+
url="https://gumroad.com",
29+
)
30+
31+
url = f"https://{username}.gumroad.com/"
32+
33+
def process(response) -> Result:
34+
if response.status_code == 404 and NOT_FOUND in response.text:
35+
return Result.available()
36+
37+
match = re.search(r'<div id="app" data-page="([^"]+)"', response.text)
38+
if response.status_code != 200 or not match:
39+
return Result.error(f"Unexpected Gumroad response: {response.status_code}")
40+
41+
try:
42+
page = json.loads(html.unescape(match.group(1)))
43+
except json.JSONDecodeError:
44+
return Result.error("Invalid Gumroad profile data")
45+
46+
props = page.get("props", {})
47+
profile = props.get("creator_profile")
48+
if (
49+
not isinstance(profile, dict)
50+
or profile.get("subdomain") != f"{username}.gumroad.com"
51+
):
52+
return Result.error("Gumroad profile did not match the requested username")
53+
54+
reputation = profile.get("reputation") or {}
55+
preview = next(
56+
(
57+
tag.get("content")
58+
for tag in props.get("_inertia_meta", [])
59+
if tag.get("property") == "og:image"
60+
),
61+
None,
62+
)
63+
links = list(
64+
dict.fromkeys(
65+
_links(
66+
[
67+
section.get("text")
68+
for section in props.get("sections", [])
69+
if section.get("type") == "SellerProfileRichTextSection"
70+
]
71+
)
72+
)
73+
)
74+
result = Result.taken(
75+
extra={
76+
"uid": profile.get("external_id"),
77+
"name": profile.get("name"),
78+
"bio": props.get("bio"),
79+
"twitter_handle": profile.get("twitter_handle"),
80+
"verified": profile.get("is_verified"),
81+
"rating": reputation.get("average"),
82+
"reviews": reputation.get("count"),
83+
"products": reputation.get("products_count"),
84+
},
85+
media={"avatar": profile.get("avatar_url"), "preview": preview},
986
)
87+
if links:
88+
cast(dict, result.extra)["links"] = links
89+
return result
1090

11-
url = f"https://{user}.gumroad.com/"
12-
show_url = f"https://{user}.gumroad.com"
13-
14-
try:
15-
response = make_request(url, follow_redirects=True)
16-
if response.status_code == 200:
17-
html = response.text
18-
extra = {}
19-
title = re.search(r'<title>([^\|]+)\|', html)
20-
if title:
21-
extra["name"] = title.group(1).strip()
22-
return Result.taken(extra=extra, url=show_url)
23-
elif response.status_code == 404:
24-
return Result.available(url=show_url)
25-
else:
26-
return Result.error(f"Unexpected status: {response.status_code}", url=show_url)
27-
except Exception as e:
28-
return Result.error(e, url=show_url)
91+
return generic_validate(url, process, show_url=url, follow_redirects=True)

0 commit comments

Comments
 (0)