Skip to content

Commit aaebf2e

Browse files
authored
feat: add Rumble new username module, rename current module to rumble_channel (#633)
* feat: add Rumble new username module, rename current module to rumble_channel * fix(user_scan): verify Rumble namespace markers - Distinguish 404 and 410 responses - Require canonical URL, creator ID, and channel ID verification
1 parent 8814587 commit aaebf2e

2 files changed

Lines changed: 196 additions & 33 deletions

File tree

Lines changed: 86 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,104 @@
1+
import html
2+
import json
13
import re
2-
from user_scanner.core.orchestrator import generic_validate
4+
from urllib.parse import quote
5+
6+
from curl_cffi.requests.exceptions import RequestException
7+
8+
from user_scanner.core.impersonate import impersonate_request, impersonate_validate
39
from user_scanner.core.result import Result
410

11+
CANONICAL_RE = re.compile(r"<link rel=canonical href=([^\s>]+)>", re.IGNORECASE)
12+
DESCRIPTION_RE = re.compile(
13+
r'<div class="channel-about--description">.*?<p>(.*?)</p>',
14+
re.IGNORECASE | re.DOTALL,
15+
)
16+
FOLLOWERS_RE = re.compile(
17+
r"<span>\s*([\d.,]+[KMB]?)\s+Followers?\s*</span>", re.IGNORECASE
18+
)
19+
IMAGE_RE = re.compile(
20+
r'class="channel-header--(img|backsplash-img)"[^>]+src="([^"]+)"',
21+
re.IGNORECASE,
22+
)
23+
NOT_FOUND_MARKERS = {
24+
404: "<title>404 Not found</title>",
25+
410: "<title>410 Gone</title>",
26+
}
27+
SOCIAL_RE = re.compile(r'<a href="([^"]+)" class="channel-subheader--socials-item"')
28+
STAT_RE = re.compile(r"([\d,]+)\s+(views|videos)\s*</p>", re.IGNORECASE)
29+
VERIFIED_RE = re.compile(r'<svg class="channel-header--verified\b')
30+
531

632
def validate_rumble(user: str) -> Result:
7-
"""Validate a channel/creator on Rumble (rumble.com)."""
8-
url = f"https://rumble.com/c/{user}"
9-
show_url = f"https://rumble.com/c/{user}"
10-
headers = {
11-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
12-
}
33+
"""Validate a Rumble user account."""
34+
url = f"https://rumble.com/user/{quote(user, safe='')}"
1335

1436
def process(response):
15-
response_text_lower = response.text.lower()
37+
marker = NOT_FOUND_MARKERS.get(response.status_code)
38+
if marker and marker in response.text:
39+
return Result.available()
1640

17-
# 1. Explicit verification of available / not-found state (HTTP 404 or 404 title)
18-
title_match = re.search(r"<title>(.*?)</title>", response.text, re.IGNORECASE)
19-
page_title = title_match.group(1).strip() if title_match else ""
41+
if response.status_code != 200:
42+
return Result.error(f"Unexpected response status: {response.status_code}")
2043

44+
canonical = CANONICAL_RE.search(response.text)
45+
creator_id = re.search(r'"creator_id":\s*"(\d+)"', response.text)
2146
if (
22-
response.status_code == 404
23-
or "404 not found" in page_title.lower()
24-
or "page not found" in response_text_lower
47+
not canonical
48+
or canonical.group(1).casefold() != str(response.url).casefold()
49+
or not creator_id
2550
):
26-
return Result.available(url=show_url)
51+
return Result.error("Rumble user markers do not match the response")
2752

28-
# 2. Explicit verification of taken state + data extraction
29-
if response.status_code == 200 and "rumble" in response_text_lower:
30-
if page_title and "404" not in page_title:
31-
extra = {"channel": user}
32-
media = {}
53+
extra = {"creator_id": creator_id.group(1)}
3354

34-
channel_name = page_title.replace(" - Rumble", "").replace(" on Rumble", "").strip()
35-
if channel_name and channel_name.lower() != "rumble":
36-
extra["name"] = channel_name
55+
profile = {}
56+
profile_start = re.search(
57+
rf'\{{"type":"user","url":"{re.escape(canonical.group(1))}"',
58+
response.text,
59+
re.IGNORECASE,
60+
)
61+
if profile_start:
62+
try:
63+
profile = json.JSONDecoder().raw_decode(
64+
response.text[profile_start.start() :]
65+
)[0]
66+
except json.JSONDecodeError:
67+
pass
3768

38-
og_img_match = re.search(r'<meta property="og:image" content="(.*?)"', response.text, re.IGNORECASE)
39-
if og_img_match:
40-
img_url = og_img_match.group(1).strip()
41-
if img_url and "rumble-logo" not in img_url.lower():
42-
media["avatar"] = img_url
69+
for field in ("followers", "subscribers"):
70+
if isinstance(profile.get(field), int):
71+
extra[field] = profile[field]
72+
if "followers" not in extra and (followers := FOLLOWERS_RE.search(response.text)):
73+
value = followers.group(1).replace(",", "")
74+
extra["followers"] = int(value) if value.isdigit() else value
75+
extra["verified"] = profile.get(
76+
"verified_badge", bool(VERIFIED_RE.search(response.text))
77+
)
78+
if badge_type := profile.get("badge_type"):
79+
extra["badge_type"] = badge_type
80+
if social_links := SOCIAL_RE.findall(response.text):
81+
extra["social_links"] = [html.unescape(link) for link in social_links]
4382

44-
return Result.taken(extra=extra, media=media, url=show_url)
83+
try:
84+
about_response = impersonate_request(f"{url}/about")
85+
except RequestException:
86+
about_response = None
87+
if about_response is not None and about_response.status_code == 200:
88+
if description := DESCRIPTION_RE.search(about_response.text):
89+
extra["description"] = " ".join(
90+
html.unescape(re.sub(r"<[^>]+>", " ", description.group(1))).split()
91+
)
92+
if joined := re.search(r"Joined\s+([^<]+)", about_response.text):
93+
extra["joined"] = joined.group(1).strip()
94+
for value, field in STAT_RE.findall(about_response.text):
95+
extra[field.lower()] = int(value.replace(",", ""))
4596

46-
return Result.error("Could not verify channel details on Rumble", url=show_url)
97+
media = {
98+
"avatar" if kind == "img" else "banner": html.unescape(image_url)
99+
for kind, image_url in IMAGE_RE.findall(response.text)
100+
}
47101

48-
# 3. Graceful error for unexpected status codes (No bare else!)
49-
return Result.error(f"Unexpected response status: {response.status_code}", url=show_url)
102+
return Result.taken(extra=extra, media=media)
50103

51-
return generic_validate(url, process, headers=headers, show_url=show_url, follow_redirects=True)
104+
return impersonate_validate(url, process, allow_redirects=True)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import html
2+
import json
3+
import re
4+
from urllib.parse import quote
5+
6+
from curl_cffi.requests.exceptions import RequestException
7+
8+
from user_scanner.core.impersonate import impersonate_request, impersonate_validate
9+
from user_scanner.core.result import Result
10+
11+
CANONICAL_RE = re.compile(r"<link rel=canonical href=([^\s>]+)>", re.IGNORECASE)
12+
DESCRIPTION_RE = re.compile(
13+
r'<div class="channel-about--description">.*?<p>(.*?)</p>',
14+
re.IGNORECASE | re.DOTALL,
15+
)
16+
FOLLOWERS_RE = re.compile(
17+
r"<span>\s*([\d.,]+[KMB]?)\s+Followers?\s*</span>", re.IGNORECASE
18+
)
19+
IMAGE_RE = re.compile(
20+
r'class="channel-header--(img|backsplash-img)"[^>]+src="([^"]+)"',
21+
re.IGNORECASE,
22+
)
23+
NOT_FOUND_TITLES = {404: "404 not found", 410: "410 gone"}
24+
SOCIAL_RE = re.compile(r'<a href="([^"]+)" class="channel-subheader--socials-item"')
25+
STAT_RE = re.compile(r"([\d,]+)\s+(views|videos)\s*</p>", re.IGNORECASE)
26+
TITLE_RE = re.compile(r"<title>([^<]*)</title>", re.IGNORECASE)
27+
VERIFIED_RE = re.compile(r'<svg class="channel-header--verified\b')
28+
29+
30+
def validate_rumble_channel(user: str) -> Result:
31+
"""Validate a Rumble channel."""
32+
url = f"https://rumble.com/c/{quote(user, safe='')}"
33+
34+
def process(response):
35+
title_match = TITLE_RE.search(response.text)
36+
title = html.unescape(title_match.group(1)).strip() if title_match else ""
37+
38+
if title.casefold() == NOT_FOUND_TITLES.get(response.status_code):
39+
return Result.available()
40+
41+
if response.status_code != 200:
42+
return Result.error(f"Unexpected response status: {response.status_code}")
43+
44+
canonical = CANONICAL_RE.search(response.text)
45+
creator_id = re.search(r'"creator_id":\s*"(\d+)"', response.text)
46+
channel_id = re.search(r'"channel_id":\s*"(\d+)"', response.text)
47+
if (
48+
not canonical
49+
or canonical.group(1).casefold() != str(response.url).casefold()
50+
or not creator_id
51+
or not channel_id
52+
):
53+
return Result.error("Rumble channel markers do not match the response")
54+
55+
extra = {
56+
"name": title,
57+
"creator_id": creator_id.group(1),
58+
"channel_id": channel_id.group(1),
59+
}
60+
61+
profile = {}
62+
profile_start = re.search(
63+
rf'\{{"type":"channel","url":"{re.escape(canonical.group(1))}"',
64+
response.text,
65+
re.IGNORECASE,
66+
)
67+
if profile_start:
68+
try:
69+
profile = json.JSONDecoder().raw_decode(
70+
response.text[profile_start.start() :]
71+
)[0]
72+
except json.JSONDecodeError:
73+
pass
74+
75+
for field in ("followers", "subscribers"):
76+
if isinstance(profile.get(field), int):
77+
extra[field] = profile[field]
78+
if "followers" not in extra and (followers := FOLLOWERS_RE.search(response.text)):
79+
value = followers.group(1).replace(",", "")
80+
extra["followers"] = int(value) if value.isdigit() else value
81+
extra["verified"] = profile.get(
82+
"verified_badge", bool(VERIFIED_RE.search(response.text))
83+
)
84+
if badge_type := profile.get("badge_type"):
85+
extra["badge_type"] = badge_type
86+
if social_links := SOCIAL_RE.findall(response.text):
87+
extra["social_links"] = [html.unescape(link) for link in social_links]
88+
89+
try:
90+
about_response = impersonate_request(f"{url}/about")
91+
except RequestException:
92+
about_response = None
93+
if about_response is not None and about_response.status_code == 200:
94+
if description := DESCRIPTION_RE.search(about_response.text):
95+
extra["description"] = " ".join(
96+
html.unescape(re.sub(r"<[^>]+>", " ", description.group(1))).split()
97+
)
98+
if joined := re.search(r"Joined\s+([^<]+)", about_response.text):
99+
extra["joined"] = joined.group(1).strip()
100+
for value, field in STAT_RE.findall(about_response.text):
101+
extra[field.lower()] = int(value.replace(",", ""))
102+
103+
media = {
104+
"avatar" if kind == "img" else "banner": html.unescape(image_url)
105+
for kind, image_url in IMAGE_RE.findall(response.text)
106+
}
107+
108+
return Result.taken(extra=extra, media=media)
109+
110+
return impersonate_validate(url, process, allow_redirects=True)

0 commit comments

Comments
 (0)