|
| 1 | +import html |
| 2 | +import json |
1 | 3 | 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 |
3 | 9 | from user_scanner.core.result import Result |
4 | 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_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 | + |
5 | 31 |
|
6 | 32 | 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='')}" |
13 | 35 |
|
14 | 36 | 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() |
16 | 40 |
|
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}") |
20 | 43 |
|
| 44 | + canonical = CANONICAL_RE.search(response.text) |
| 45 | + creator_id = re.search(r'"creator_id":\s*"(\d+)"', response.text) |
21 | 46 | 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 |
25 | 50 | ): |
26 | | - return Result.available(url=show_url) |
| 51 | + return Result.error("Rumble user markers do not match the response") |
27 | 52 |
|
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)} |
33 | 54 |
|
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 |
37 | 68 |
|
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] |
43 | 82 |
|
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(",", "")) |
45 | 96 |
|
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 | + } |
47 | 101 |
|
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) |
50 | 103 |
|
51 | | - return generic_validate(url, process, headers=headers, show_url=show_url, follow_redirects=True) |
| 104 | + return impersonate_validate(url, process, allow_redirects=True) |
0 commit comments