Skip to content

Commit 0ca2d6f

Browse files
committed
Add Grass and Bytelixir earnings collectors
1 parent 994dd0d commit 0ca2d6f

4 files changed

Lines changed: 265 additions & 0 deletions

File tree

app/collectors/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111

1212
from app.collectors.base import BaseCollector, EarningsResult
1313
from app.collectors.bitping import BitpingCollector
14+
from app.collectors.bytelixir import BytelixirCollector
1415
from app.collectors.earnapp import EarnAppCollector
1516
from app.collectors.earnfm import EarnFMCollector
17+
from app.collectors.grass import GrassCollector
1618
from app.collectors.honeygain import HoneygainCollector
1719
from app.collectors.iproyal import IPRoyalCollector
1820
from app.collectors.mystnodes import MystNodesCollector
@@ -37,6 +39,8 @@
3739
"bitping": BitpingCollector,
3840
"earnfm": EarnFMCollector,
3941
"packetstream": PacketStreamCollector,
42+
"grass": GrassCollector,
43+
"bytelixir": BytelixirCollector,
4044
}
4145

4246
# Map of slug -> list of config keys needed to instantiate the collector
@@ -52,6 +56,8 @@
5256
"bitping": ["email", "password"],
5357
"earnfm": ["email", "password"],
5458
"packetstream": ["auth_token"],
59+
"grass": ["access_token"],
60+
"bytelixir": ["email", "password"],
5561
}
5662

5763

app/collectors/bytelixir.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Bytelixir earnings collector.
2+
3+
Authenticates via email/password to the Bytelixir dashboard API and
4+
fetches the current balance.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
11+
import httpx
12+
13+
from app.collectors.base import BaseCollector, EarningsResult
14+
15+
logger = logging.getLogger(__name__)
16+
17+
DASH_BASE = "https://dash.bytelixir.com"
18+
19+
20+
class BytelixirCollector(BaseCollector):
21+
"""Collect earnings from Bytelixir's dashboard."""
22+
23+
platform = "bytelixir"
24+
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
54+
55+
async def collect(self) -> EarningsResult:
56+
"""Fetch current Bytelixir balance."""
57+
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+
)
82+
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+
)
91+
92+
if resp.status_code == 200:
93+
data = resp.json()
94+
balance = _extract_balance(data)
95+
if balance is not None:
96+
return EarningsResult(
97+
platform=self.platform,
98+
balance=round(balance, 4),
99+
currency="USD",
100+
)
101+
102+
return EarningsResult(
103+
platform=self.platform,
104+
balance=0.0,
105+
error="Could not find balance in Bytelixir API responses",
106+
)
107+
except Exception as exc:
108+
logger.error("Bytelixir collection failed: %s", exc)
109+
return EarningsResult(
110+
platform=self.platform,
111+
balance=0.0,
112+
error=str(exc),
113+
)
114+
115+
116+
def _extract_balance(data: dict) -> float | None:
117+
"""Try to extract a USD balance from various response shapes."""
118+
# Direct balance field
119+
for key in ("balance", "total_balance", "earnings", "total_earnings"):
120+
val = data.get(key)
121+
if val is not None:
122+
try:
123+
return float(val)
124+
except (ValueError, TypeError):
125+
continue
126+
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
148+
149+
return None

app/collectors/grass.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Grass earnings collector.
2+
3+
Uses the Grass API with an access token (from browser session) to fetch
4+
cumulative points. Grass uses OTP email login with no password, so users
5+
must provide their access token from the browser.
6+
7+
To get the token: open app.grass.io, log in, then run in the browser console:
8+
localStorage.getItem('token')
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import logging
14+
15+
import httpx
16+
17+
from app.collectors.base import BaseCollector, EarningsResult
18+
19+
logger = logging.getLogger(__name__)
20+
21+
API_BASE = "https://api.getgrass.io"
22+
23+
24+
class GrassCollector(BaseCollector):
25+
"""Collect earnings from Grass's API using an access token."""
26+
27+
platform = "grass"
28+
29+
def __init__(self, access_token: str) -> None:
30+
self.access_token = access_token
31+
32+
async def collect(self) -> EarningsResult:
33+
"""Fetch current Grass points."""
34+
try:
35+
headers = {
36+
"Authorization": self.access_token,
37+
"Accept": "application/json, text/plain, */*",
38+
"Origin": "https://app.grass.io",
39+
"Referer": "https://app.grass.io/",
40+
"User-Agent": "Mozilla/5.0",
41+
}
42+
43+
async with httpx.AsyncClient(timeout=30) as client:
44+
resp = await client.get(
45+
f"{API_BASE}/users/earnings/epochs",
46+
headers=headers,
47+
)
48+
49+
if resp.status_code in (401, 403):
50+
return EarningsResult(
51+
platform=self.platform,
52+
balance=0.0,
53+
error="Token expired — get a new one from app.grass.io browser console: localStorage.getItem('token')",
54+
)
55+
56+
resp.raise_for_status()
57+
data = resp.json()
58+
59+
# Extract total cumulative points from epochs
60+
epoch_earnings = data.get("result", {}).get("data", {}).get("epochEarnings", [])
61+
total_points = 0.0
62+
if epoch_earnings:
63+
total_points = float(epoch_earnings[0].get("totalCumulativePoints", 0))
64+
65+
return EarningsResult(
66+
platform=self.platform,
67+
balance=round(total_points, 4),
68+
currency="GRASS_POINTS",
69+
)
70+
except Exception as exc:
71+
logger.error("Grass collection failed: %s", exc)
72+
return EarningsResult(
73+
platform=self.platform,
74+
balance=0.0,
75+
error=str(exc),
76+
)

app/templates/settings.html

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,40 @@ <h3 class="settings-section-title">Earnings Collection</h3>
140140
</div>
141141
</details>
142142

143+
<!-- Grass -->
144+
<details class="collector-section">
145+
<summary class="collector-header">
146+
<span class="collector-name">Grass</span>
147+
<span class="badge badge-category" id="status-grass">Not configured</span>
148+
</summary>
149+
<div class="collector-body">
150+
<div class="form-group">
151+
<label class="form-label">Access Token</label>
152+
<input class="form-input collector-input" type="password" data-config="grass_access_token"
153+
placeholder="From browser: F12 > Console > localStorage.getItem('token')">
154+
<div class="form-hint">Open <a href="https://app.grass.io" target="_blank">app.grass.io</a>, log in, press F12, go to Console, and run: <code>localStorage.getItem('token')</code></div>
155+
</div>
156+
</div>
157+
</details>
158+
159+
<!-- Bytelixir -->
160+
<details class="collector-section">
161+
<summary class="collector-header">
162+
<span class="collector-name">Bytelixir</span>
163+
<span class="badge badge-category" id="status-bytelixir">Not configured</span>
164+
</summary>
165+
<div class="collector-body">
166+
<div class="form-group">
167+
<label class="form-label">Email</label>
168+
<input class="form-input collector-input" type="text" data-config="bytelixir_email" placeholder="Your Bytelixir email">
169+
</div>
170+
<div class="form-group">
171+
<label class="form-label">Password</label>
172+
<input class="form-input collector-input" type="password" data-config="bytelixir_password" placeholder="Your Bytelixir password">
173+
</div>
174+
</div>
175+
</details>
176+
143177
<div style="margin-top: 16px; display: flex; gap: 8px; align-items: center;">
144178
<button class="btn btn-primary" onclick="CP.saveCollectorCredentials()">Save Credentials</button>
145179
<button class="btn btn-secondary" onclick="CP.testCollectors()">Test Collection Now</button>

0 commit comments

Comments
 (0)