11"""Bytelixir earnings collector.
22
3- Authenticates via email/password to the Bytelixir dashboard API and
4- fetches the current balance.
3+ Uses session cookie from the browser to fetch balance. Bytelixir is a
4+ Laravel app with hCaptcha on login, so automated email/password login
5+ is not possible. Users must extract session cookies from their browser.
6+
7+ To get the cookie: open dash.bytelixir.com, log in (tick "Remember Me"),
8+ press F12 > Application > Cookies, and copy the `bytelixir_session` value.
59"""
610
711from __future__ import annotations
812
913import logging
14+ import re
1015
1116import httpx
1217
1823
1924
2025class BytelixirCollector (BaseCollector ):
21- """Collect earnings from Bytelixir's dashboard ."""
26+ """Collect earnings from Bytelixir using a session cookie ."""
2227
2328 platform = "bytelixir"
2429
25- def __init__ (self , email : str , password : str ) -> None :
26- self .email = email
27- self .password = password
28- self ._token : str | None = None
29-
30- async def _authenticate (self , client : httpx .AsyncClient ) -> str :
31- """Log in and obtain a session/token."""
32- resp = await client .post (
33- f"{ DASH_BASE } /api/auth/login" ,
34- json = {"email" : self .email , "password" : self .password },
35- headers = {
36- "User-Agent" : "Mozilla/5.0" ,
37- "Origin" : DASH_BASE ,
38- "Referer" : f"{ DASH_BASE } /" ,
39- },
40- )
41- resp .raise_for_status ()
42- data = resp .json ()
43-
44- # Try common token locations in response
45- token = (
46- data .get ("token" )
47- or data .get ("access_token" )
48- or data .get ("data" , {}).get ("token" , "" )
49- or data .get ("data" , {}).get ("access_token" , "" )
50- )
51- if not token :
52- raise ValueError (f"No token in Bytelixir login response (keys: { list (data .keys ())} )" )
53- return token
30+ def __init__ (self , session_cookie : str ) -> None :
31+ self .session_cookie = session_cookie
5432
5533 async def collect (self ) -> EarningsResult :
5634 """Fetch current Bytelixir balance."""
5735 try :
58- async with httpx .AsyncClient (timeout = 30 , follow_redirects = True ) as client :
59- if not self ._token :
60- self ._token = await self ._authenticate (client )
61-
62- headers = {
63- "Authorization" : f"Bearer { self ._token } " ,
64- "User-Agent" : "Mozilla/5.0" ,
65- "Accept" : "application/json" ,
66- "Origin" : DASH_BASE ,
67- "Referer" : f"{ DASH_BASE } /" ,
68- }
69-
70- # Try the dashboard/earnings endpoints
71- for path in (
72- "/api/user/balance" ,
73- "/api/user/earnings" ,
74- "/api/dashboard" ,
75- "/api/user" ,
76- "/api/me" ,
77- ):
78- resp = await client .get (
79- f"{ DASH_BASE } { path } " ,
80- headers = headers ,
81- )
36+ cookies = {"bytelixir_session" : self .session_cookie }
37+ headers = {
38+ "User-Agent" : "Mozilla/5.0" ,
39+ "Accept" : "application/json, text/html" ,
40+ "X-Requested-With" : "XMLHttpRequest" ,
41+ "Referer" : f"{ DASH_BASE } /" ,
42+ }
8243
83- # Token expired — retry auth once
84- if resp .status_code == 401 :
85- self ._token = await self ._authenticate (client )
86- headers ["Authorization" ] = f"Bearer { self ._token } "
87- resp = await client .get (
88- f"{ DASH_BASE } { path } " ,
89- headers = headers ,
90- )
44+ async with httpx .AsyncClient (timeout = 30 , follow_redirects = True , cookies = cookies ) as client :
45+ # Try the JSON API first
46+ resp = await client .get (
47+ f"{ DASH_BASE } /api/v1/user" ,
48+ headers = headers ,
49+ )
9150
92- if resp .status_code == 200 :
51+ if resp .status_code == 200 :
52+ try :
9353 data = resp .json ()
9454 balance = _extract_balance (data )
9555 if balance is not None :
@@ -98,11 +58,36 @@ async def collect(self) -> EarningsResult:
9858 balance = round (balance , 4 ),
9959 currency = "USD" ,
10060 )
61+ except Exception :
62+ pass
63+
64+ # Fallback: scrape the dashboard HTML for data-balance
65+ resp = await client .get (
66+ f"{ DASH_BASE } /en" ,
67+ headers = {** headers , "Accept" : "text/html" },
68+ )
69+
70+ if resp .status_code == 200 :
71+ balance = _extract_balance_from_html (resp .text )
72+ if balance is not None :
73+ return EarningsResult (
74+ platform = self .platform ,
75+ balance = round (balance , 4 ),
76+ currency = "USD" ,
77+ )
78+
79+ # Check if session expired (redirected to login)
80+ if "/sign-in" in str (resp .url ):
81+ return EarningsResult (
82+ platform = self .platform ,
83+ balance = 0.0 ,
84+ error = "Session expired — get a new bytelixir_session cookie from your browser" ,
85+ )
10186
10287 return EarningsResult (
10388 platform = self .platform ,
10489 balance = 0.0 ,
105- error = "Could not find balance in Bytelixir API responses " ,
90+ error = "Could not extract balance from Bytelixir dashboard " ,
10691 )
10792 except Exception as exc :
10893 logger .error ("Bytelixir collection failed: %s" , exc )
@@ -114,8 +99,7 @@ async def collect(self) -> EarningsResult:
11499
115100
116101def _extract_balance (data : dict ) -> float | None :
117- """Try to extract a USD balance from various response shapes."""
118- # Direct balance field
102+ """Try to extract a USD balance from JSON response."""
119103 for key in ("balance" , "total_balance" , "earnings" , "total_earnings" ):
120104 val = data .get (key )
121105 if val is not None :
@@ -124,26 +108,35 @@ def _extract_balance(data: dict) -> float | None:
124108 except (ValueError , TypeError ):
125109 continue
126110
127- # Nested under 'data'
128- inner = data .get ("data" , {})
129- if isinstance (inner , dict ):
130- for key in ("balance" , "total_balance" , "earnings" , "total_earnings" ):
131- val = inner .get (key )
132- if val is not None :
133- try :
134- return float (val )
135- except (ValueError , TypeError ):
136- continue
137-
138- # Nested under 'user'
139- user = data .get ("user" , {})
140- if isinstance (user , dict ):
141- for key in ("balance" , "earnings" ):
142- val = user .get (key )
143- if val is not None :
144- try :
145- return float (val )
146- except (ValueError , TypeError ):
147- continue
111+ # Nested under 'data' or 'user'
112+ for wrapper in ("data" , "user" ):
113+ inner = data .get (wrapper , {})
114+ if isinstance (inner , dict ):
115+ for key in ("balance" , "total_balance" , "earnings" , "total_earnings" ):
116+ val = inner .get (key )
117+ if val is not None :
118+ try :
119+ return float (val )
120+ except (ValueError , TypeError ):
121+ continue
122+ return None
123+
124+
125+ def _extract_balance_from_html (html : str ) -> float | None :
126+ """Extract balance from data-balance attribute in dashboard HTML."""
127+ match = re .search (r'data-balance=["\']([^"\']+)["\']' , html )
128+ if match :
129+ try :
130+ return float (match .group (1 ))
131+ except (ValueError , TypeError ):
132+ pass
133+
134+ # Also try matching a balance-like number near "balance" text
135+ match = re .search (r"(?:balance|earnings)[^>]*>\s*\$?\s*([\d.]+)" , html , re .IGNORECASE )
136+ if match :
137+ try :
138+ return float (match .group (1 ))
139+ except (ValueError , TypeError ):
140+ pass
148141
149142 return None
0 commit comments