jugs 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jugs/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from jugs.client import JugsClient
4
+ from jugs.exceptions import (
5
+ AuthError,
6
+ JugsError,
7
+ NotFoundError,
8
+ ProxyError,
9
+ RateLimitError,
10
+ ValidationError,
11
+ )
12
+ from jugs.models import (
13
+ ActionResult,
14
+ ClickResult,
15
+ Comment,
16
+ CommentsResult,
17
+ DiscordMember,
18
+ DiscordUser,
19
+ PresenceResult,
20
+ StatusResult,
21
+ ViewResult,
22
+ )
23
+
24
+ __all__ = (
25
+ "JugsClient",
26
+ "JugsError",
27
+ "AuthError",
28
+ "RateLimitError",
29
+ "NotFoundError",
30
+ "ValidationError",
31
+ "ProxyError",
32
+ "ViewResult",
33
+ "StatusResult",
34
+ "Comment",
35
+ "CommentsResult",
36
+ "DiscordUser",
37
+ "DiscordMember",
38
+ "PresenceResult",
39
+ "ClickResult",
40
+ "ActionResult",
41
+ )
42
+
43
+ __version__ = "1.0.0"
jugs/auth.py ADDED
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import orjson
6
+
7
+ from jugs.constants import ANON_KEY, BASE_URL, SUPABASE_URL
8
+ from jugs.exceptions import AuthError
9
+ from jugs.http import HTTPSession
10
+
11
+
12
+ class AuthManager:
13
+ __slots__ = ("_http", "_access_token", "_refresh_token", "_user")
14
+
15
+ def __init__(self, http: HTTPSession) -> None:
16
+ self._http = http
17
+ self._access_token: str | None = None
18
+ self._refresh_token: str | None = None
19
+ self._user: dict[str, Any] | None = None
20
+
21
+ @property
22
+ def access_token(self) -> str | None:
23
+ return self._access_token
24
+
25
+ @property
26
+ def is_authenticated(self) -> bool:
27
+ return self._access_token is not None
28
+
29
+ @property
30
+ def user(self) -> dict[str, Any] | None:
31
+ return self._user
32
+
33
+ def _supabase_headers(self) -> dict[str, str]:
34
+ headers = {
35
+ "apikey": ANON_KEY,
36
+ "Content-Type": "application/json",
37
+ }
38
+
39
+ if self._access_token:
40
+ headers["Authorization"] = f"Bearer {self._access_token}"
41
+
42
+ return headers
43
+
44
+ def login_email(self, email: str, password: str) -> dict[str, Any]:
45
+ url = f"{SUPABASE_URL}/auth/v1/token?grant_type=password"
46
+
47
+ payload = {
48
+ "email": email,
49
+ "password": password,
50
+ }
51
+
52
+ resp = self._http.post(
53
+ url,
54
+ headers=self._supabase_headers(),
55
+ json_data=payload,
56
+ )
57
+
58
+ if resp.status_code != 200:
59
+ raise AuthError(f"Login failed: {resp.status_code} {resp.text}")
60
+
61
+ data = orjson.loads(resp.content)
62
+
63
+ self._access_token = data.get("access_token")
64
+ self._refresh_token = data.get("refresh_token")
65
+ self._user = data.get("user")
66
+
67
+ if self._access_token:
68
+ self._http.set_cookie("sb-access-token", self._access_token)
69
+
70
+ return data
71
+
72
+ def signup_email(self, email: str, password: str) -> dict[str, Any]:
73
+ url = f"{SUPABASE_URL}/auth/v1/signup"
74
+
75
+ payload = {
76
+ "email": email,
77
+ "password": password,
78
+ }
79
+
80
+ resp = self._http.post(
81
+ url,
82
+ headers=self._supabase_headers(),
83
+ json_data=payload,
84
+ )
85
+
86
+ if resp.status_code not in (200, 201):
87
+ raise AuthError(f"Signup failed: {resp.status_code} {resp.text}")
88
+
89
+ data = orjson.loads(resp.content)
90
+
91
+ self._access_token = data.get("access_token")
92
+ self._refresh_token = data.get("refresh_token")
93
+ self._user = data.get("user") or (data if data.get("id") else None)
94
+
95
+ return data
96
+
97
+ def refresh(self) -> dict[str, Any]:
98
+ if not self._refresh_token:
99
+ raise AuthError("No refresh token available")
100
+
101
+ url = f"{SUPABASE_URL}/auth/v1/token?grant_type=refresh_token"
102
+
103
+ payload = {
104
+ "refresh_token": self._refresh_token,
105
+ }
106
+
107
+ resp = self._http.post(
108
+ url,
109
+ headers=self._supabase_headers(),
110
+ json_data=payload,
111
+ )
112
+
113
+ if resp.status_code != 200:
114
+ raise AuthError(f"Refresh failed: {resp.status_code} {resp.text}")
115
+
116
+ data = orjson.loads(resp.content)
117
+
118
+ self._access_token = data.get("access_token")
119
+ self._refresh_token = data.get("refresh_token")
120
+ self._user = data.get("user")
121
+
122
+ return data
123
+
124
+ def logout(self) -> None:
125
+ if not self._access_token:
126
+ return
127
+
128
+ url = f"{SUPABASE_URL}/auth/v1/logout"
129
+
130
+ self._http.post(
131
+ url,
132
+ headers=self._supabase_headers(),
133
+ )
134
+
135
+ self._access_token = None
136
+ self._refresh_token = None
137
+ self._user = None
138
+ self._http.clear_cookies()
139
+
140
+ def load_session(
141
+ self,
142
+ access_token: str,
143
+ refresh_token: str | None = None,
144
+ ) -> None:
145
+ self._access_token = access_token
146
+ self._refresh_token = refresh_token
147
+ self._http.set_cookie("sb-access-token", access_token)
148
+
149
+ def discord_oauth_url(self, redirect_to: str = f"{BASE_URL}/dashboard") -> str:
150
+ return (
151
+ f"{SUPABASE_URL}/auth/v1/authorize"
152
+ f"?provider=discord"
153
+ f"&redirect_to={redirect_to}"
154
+ )
155
+
156
+ def get_session(self) -> dict[str, Any] | None:
157
+ if not self._access_token:
158
+ return None
159
+
160
+ url = f"{SUPABASE_URL}/auth/v1/user"
161
+
162
+ resp = self._http.get(
163
+ url,
164
+ headers=self._supabase_headers(),
165
+ )
166
+
167
+ if resp.status_code != 200:
168
+ return None
169
+
170
+ self._user = orjson.loads(resp.content)
171
+
172
+ return self._user
jugs/client.py ADDED
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence
4
+
5
+ from jugs.auth import AuthManager
6
+ from jugs.constants import RESERVED_USERNAMES
7
+ from jugs.endpoints.actions import ActionsAPI
8
+ from jugs.endpoints.discord import DiscordAPI
9
+ from jugs.endpoints.sys import SysAPI
10
+ from jugs.exceptions import ValidationError
11
+ from jugs.http import HTTPSession
12
+ from jugs.models import (
13
+ ActionResult,
14
+ ClickResult,
15
+ CommentsResult,
16
+ PresenceResult,
17
+ StatusResult,
18
+ ViewResult,
19
+ )
20
+
21
+
22
+ class JugsClient:
23
+ __slots__ = (
24
+ "_http",
25
+ "_auth",
26
+ "_sys",
27
+ "_discord",
28
+ "_actions",
29
+ "username",
30
+ )
31
+
32
+ def __init__(
33
+ self,
34
+ username: str = "c",
35
+ *,
36
+ proxies: Sequence[str] | None = None,
37
+ timeout: float = 10.0,
38
+ http2: bool = False,
39
+ ) -> None:
40
+ if username.lower() in RESERVED_USERNAMES:
41
+ raise ValidationError(f"Username '{username}' is reserved")
42
+
43
+ self.username = username
44
+ self._http = HTTPSession(proxies=proxies, timeout=timeout, http2=http2)
45
+ self._auth = AuthManager(self._http)
46
+ self._sys = SysAPI(self._http)
47
+ self._discord = DiscordAPI(self._http)
48
+ self._actions = ActionsAPI(self._http)
49
+
50
+ @property
51
+ def auth(self) -> AuthManager:
52
+ return self._auth
53
+
54
+ @property
55
+ def is_authenticated(self) -> bool:
56
+ return self._auth.is_authenticated
57
+
58
+ def view(self, username: str | None = None, *, rotate: bool = False) -> ViewResult:
59
+ return self._sys.view(username or self.username, rotate=rotate)
60
+
61
+ def status(self, username: str | None = None) -> StatusResult:
62
+ return self._sys.status(username or self.username)
63
+
64
+ def likes(self, username: str | None = None) -> StatusResult:
65
+ return self._sys.likes(username or self.username)
66
+
67
+ def click(self, target_url: str, username: str | None = None) -> ClickResult:
68
+ return self._sys.click(username or self.username, target_url)
69
+
70
+ def comments(self, username: str | None = None) -> CommentsResult:
71
+ return self._sys.comments(username or self.username)
72
+
73
+ def post_comment(self, content: str, username: str | None = None) -> dict[str, Any]:
74
+ return self._sys.post_comment(username or self.username, content)
75
+
76
+ def presence(self, discord_id: str) -> PresenceResult:
77
+ return self._discord.presence(discord_id)
78
+
79
+ def audio_proxy(self, audio_url: str) -> bytes:
80
+ return self._sys.audio_proxy(audio_url)
81
+
82
+ def tip(self, payload: list[Any] | None = None) -> ActionResult:
83
+ return self._actions.tip(payload)
84
+
85
+ def claim_drop(self, drop_id: str) -> ActionResult:
86
+ return self._actions.claim_drop(drop_id)
87
+
88
+ def claim_crate(self, crate_id: str) -> ActionResult:
89
+ return self._actions.claim_crate(crate_id)
90
+
91
+ def get_unseen_drops(self) -> ActionResult:
92
+ return self._actions.get_unseen_drops()
93
+
94
+ def get_unseen_crates(self) -> ActionResult:
95
+ return self._actions.get_unseen_crates()
96
+
97
+ def mark_drops_seen(self) -> ActionResult:
98
+ return self._actions.mark_drops_seen()
99
+
100
+ def mark_crates_seen(self) -> ActionResult:
101
+ return self._actions.mark_crates_seen()
102
+
103
+ def bug_check(self) -> dict[str, Any]:
104
+ return self._sys.bug_check()
105
+
106
+ def bug_report(
107
+ self,
108
+ title: str,
109
+ description: str,
110
+ steps: str,
111
+ expected: str,
112
+ actual: str,
113
+ browser: str = "",
114
+ device: str = "",
115
+ ) -> dict[str, Any]:
116
+ return self._sys.bug_report(
117
+ title, description, steps, expected, actual, browser, device
118
+ )
119
+
120
+ def suggest_check(self) -> dict[str, Any]:
121
+ return self._sys.suggest_check()
122
+
123
+ def suggest(self, suggestion: str, context: str = "") -> dict[str, Any]:
124
+ return self._sys.suggest(suggestion, context)
125
+
126
+ def login(self, email: str, password: str) -> dict[str, Any]:
127
+ return self._auth.login_email(email, password)
128
+
129
+ def signup(self, email: str, password: str) -> dict[str, Any]:
130
+ return self._auth.signup_email(email, password)
131
+
132
+ def logout(self) -> None:
133
+ self._auth.logout()
134
+
135
+ def load_session(
136
+ self,
137
+ access_token: str,
138
+ refresh_token: str | None = None,
139
+ ) -> None:
140
+ self._auth.load_session(access_token, refresh_token)
141
+
142
+ def refresh_session(self) -> dict[str, Any]:
143
+ return self._auth.refresh()
144
+
145
+ def discord_oauth_url(self, redirect_to: str | None = None) -> str:
146
+ if redirect_to is None:
147
+ return self._auth.discord_oauth_url()
148
+
149
+ return self._auth.discord_oauth_url(redirect_to)
150
+
151
+ def close(self) -> None:
152
+ self._http.close()
153
+
154
+ def __enter__(self) -> JugsClient:
155
+ return self
156
+
157
+ def __exit__(self, *args: object) -> None:
158
+ self.close()
jugs/constants.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ BASE_URL: str = "https://www.jugs.lol"
4
+
5
+ SUPABASE_URL: str = "https://ckenyrstsktunenpsubb.supabase.co"
6
+
7
+ ANON_KEY: str = (
8
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
9
+ "eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNrZW55cnN0c2t0dW5lbnBzdWJiIiwicm9sZSI6ImFub24i"
10
+ "LCJpYXQiOjE3OTE3NzE5ODUsImV4cCI6MjEwNzM0Nzk4NX0."
11
+ "Ggla0o3HGl54t4nj_8gQ67EJDs9UhKlipfhkIrsxg8I"
12
+ )
13
+
14
+ DEFAULT_UA: str = (
15
+ "Mozilla/5.0 (Linux; Android 16; moto g play - 2026) "
16
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
17
+ "Chrome/155.0.8059.43 Mobile Safari/537.36"
18
+ )
19
+
20
+ ACTION_TIP: str = "70111c9a8fae4bad063a0c4c19787ed3a85c2daa18"
21
+
22
+ ACTION_CLAIM_DROP: str = "40f6997701608f4b9c8cb2f93bf435e26f320c02e9"
23
+
24
+ ACTION_CLAIM_CRATE: str = "403ac738f0b8e509578fdd36336e1ba090ddd1f2d4"
25
+
26
+ ACTION_GET_UNSEEN_DROPS: str = "005deb07fb33ea758655b1173a09d7b399321dfdb5"
27
+
28
+ ACTION_GET_UNSEEN_CRATES: str = "00d777d5d051e684420de77ee2eb388b06c64ad77b"
29
+
30
+ ACTION_MARK_DROPS_SEEN: str = "4035680f86329d45bf40e27a07881bb193653b9610"
31
+
32
+ ACTION_MARK_CRATES_SEEN: str = "409baa837993b269490d97013b1c9529a010fcb913"
33
+
34
+ RESERVED_USERNAMES: frozenset[str] = frozenset(
35
+ {
36
+ "login",
37
+ "signup",
38
+ "dashboard",
39
+ "api",
40
+ "admin",
41
+ "owner",
42
+ "auth",
43
+ "about",
44
+ "terms",
45
+ "privacy",
46
+ "leaderboard",
47
+ "directory",
48
+ "discord",
49
+ "shop",
50
+ "addons",
51
+ "claim",
52
+ "premium",
53
+ "crates",
54
+ "reset-password",
55
+ "account-restricted",
56
+ "_next",
57
+ "static",
58
+ }
59
+ )
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from jugs.endpoints.actions import ActionsAPI
4
+ from jugs.endpoints.discord import DiscordAPI
5
+ from jugs.endpoints.sys import SysAPI
6
+
7
+ __all__ = ("SysAPI", "DiscordAPI", "ActionsAPI")
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import orjson
6
+
7
+ from jugs.constants import (
8
+ ACTION_CLAIM_CRATE,
9
+ ACTION_CLAIM_DROP,
10
+ ACTION_GET_UNSEEN_CRATES,
11
+ ACTION_GET_UNSEEN_DROPS,
12
+ ACTION_MARK_CRATES_SEEN,
13
+ ACTION_MARK_DROPS_SEEN,
14
+ ACTION_TIP,
15
+ BASE_URL,
16
+ )
17
+ from jugs.http import HTTPSession
18
+ from jugs.models import ActionResult
19
+
20
+
21
+ class ActionsAPI:
22
+ __slots__ = ("_http",)
23
+
24
+ def __init__(self, http: HTTPSession) -> None:
25
+ self._http = http
26
+
27
+ def _call(
28
+ self,
29
+ action_id: str,
30
+ payload: list[Any] | None = None,
31
+ *,
32
+ path: str = "/c",
33
+ ) -> ActionResult:
34
+ if payload is None:
35
+ payload = []
36
+
37
+ url = f"{BASE_URL}{path}"
38
+
39
+ body = orjson.dumps(payload)
40
+
41
+ headers = {
42
+ "Content-Type": "text/plain;charset=UTF-8",
43
+ "Accept": "text/x-component",
44
+ "next-action": action_id,
45
+ "Origin": BASE_URL,
46
+ "Referer": f"{BASE_URL}{path}",
47
+ }
48
+
49
+ resp = self._http.post(
50
+ url,
51
+ headers=headers,
52
+ content=body,
53
+ )
54
+
55
+ raw = resp.text
56
+ data: Any = None
57
+
58
+ try:
59
+ lines = raw.strip().split("\n")
60
+
61
+ for line in lines:
62
+ if line.startswith("1:"):
63
+ data = orjson.loads(line[2:])
64
+ break
65
+ except Exception:
66
+ data = raw
67
+
68
+ return ActionResult(raw=raw, data=data)
69
+
70
+ def tip(self, payload: list[Any] | None = None) -> ActionResult:
71
+ return self._call(ACTION_TIP, payload)
72
+
73
+ def claim_drop(self, drop_id: str) -> ActionResult:
74
+ return self._call(ACTION_CLAIM_DROP, [drop_id])
75
+
76
+ def claim_crate(self, crate_id: str) -> ActionResult:
77
+ return self._call(ACTION_CLAIM_CRATE, [crate_id])
78
+
79
+ def get_unseen_drops(self) -> ActionResult:
80
+ return self._call(ACTION_GET_UNSEEN_DROPS)
81
+
82
+ def get_unseen_crates(self) -> ActionResult:
83
+ return self._call(ACTION_GET_UNSEEN_CRATES)
84
+
85
+ def mark_drops_seen(self) -> ActionResult:
86
+ return self._call(ACTION_MARK_DROPS_SEEN)
87
+
88
+ def mark_crates_seen(self) -> ActionResult:
89
+ return self._call(ACTION_MARK_CRATES_SEEN)
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import orjson
6
+
7
+ from jugs.constants import BASE_URL
8
+ from jugs.http import HTTPSession
9
+ from jugs.models import DiscordMember, DiscordUser, PresenceResult
10
+
11
+
12
+ class DiscordAPI:
13
+ __slots__ = ("_http",)
14
+
15
+ def __init__(self, http: HTTPSession) -> None:
16
+ self._http = http
17
+
18
+ def presence(self, discord_id: str) -> PresenceResult:
19
+ url = f"{BASE_URL}/api/discord/presence/{discord_id}"
20
+
21
+ resp = self._http.get(
22
+ url,
23
+ headers={"Referer": BASE_URL},
24
+ )
25
+
26
+ data = orjson.loads(resp.content)
27
+
28
+ user_data = data.get("user")
29
+ member_data = data.get("member")
30
+
31
+ user = None
32
+
33
+ if user_data:
34
+ user = DiscordUser(
35
+ id=str(user_data.get("id") or ""),
36
+ username=str(user_data.get("username") or ""),
37
+ global_name=user_data.get("global_name"),
38
+ avatar_url=user_data.get("avatar_url"),
39
+ avatar_decoration_url=user_data.get("avatar_decoration_url"),
40
+ )
41
+
42
+ member = None
43
+
44
+ if member_data:
45
+ roles = tuple(str(r) for r in (member_data.get("roles") or []))
46
+
47
+ member = DiscordMember(
48
+ display_name=member_data.get("display_name"),
49
+ guild_avatar_url=member_data.get("guild_avatar_url"),
50
+ server_booster=bool(member_data.get("server_booster")),
51
+ roles=roles,
52
+ )
53
+
54
+ activities = tuple(data.get("activities") or [])
55
+
56
+ return PresenceResult(
57
+ ok=bool(data.get("ok")),
58
+ joined=bool(data.get("joined")),
59
+ status=str(data.get("status") or "offline"),
60
+ activities=activities,
61
+ user=user,
62
+ member=member,
63
+ )
jugs/endpoints/sys.py ADDED
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import orjson
6
+
7
+ from jugs.constants import BASE_URL
8
+ from jugs.exceptions import ValidationError
9
+ from jugs.http import HTTPSession
10
+ from jugs.models import (
11
+ ClickResult,
12
+ Comment,
13
+ CommentsResult,
14
+ StatusResult,
15
+ ViewResult,
16
+ )
17
+
18
+
19
+ class SysAPI:
20
+ __slots__ = ("_http",)
21
+
22
+ def __init__(self, http: HTTPSession) -> None:
23
+ self._http = http
24
+
25
+ def _headers(self, username: str) -> dict[str, str]:
26
+ return {
27
+ "Origin": BASE_URL,
28
+ "Referer": f"{BASE_URL}/{username}",
29
+ }
30
+
31
+ def view(self, username: str, *, rotate: bool = False) -> ViewResult:
32
+ url = f"{BASE_URL}/api/sys/v"
33
+
34
+ resp = self._http.post(
35
+ url,
36
+ headers={**self._headers(username), "Content-Type": "application/json"},
37
+ json_data={},
38
+ params={"username": username},
39
+ rotate=rotate,
40
+ )
41
+
42
+ data = orjson.loads(resp.content)
43
+
44
+ return ViewResult(
45
+ ok=bool(data.get("ok")),
46
+ counted=bool(data.get("counted")),
47
+ )
48
+
49
+ def status(self, username: str) -> StatusResult:
50
+ url = f"{BASE_URL}/api/sys/b"
51
+
52
+ resp = self._http.get(
53
+ url,
54
+ headers=self._headers(username),
55
+ params={"username": username},
56
+ )
57
+
58
+ data = orjson.loads(resp.content)
59
+
60
+ return StatusResult(
61
+ ok=bool(data.get("ok")),
62
+ bookmarked=bool(data.get("bookmarked")),
63
+ liked=bool(data.get("liked")),
64
+ bookmarks=int(data.get("bookmarks") or 0),
65
+ likes=int(data.get("likes") or 0),
66
+ )
67
+
68
+ def likes(self, username: str) -> StatusResult:
69
+ url = f"{BASE_URL}/api/sys/l"
70
+
71
+ resp = self._http.get(
72
+ url,
73
+ headers=self._headers(username),
74
+ params={"username": username},
75
+ )
76
+
77
+ data = orjson.loads(resp.content)
78
+
79
+ return StatusResult(
80
+ ok=bool(data.get("ok")),
81
+ bookmarked=bool(data.get("bookmarked")),
82
+ liked=bool(data.get("liked")),
83
+ bookmarks=int(data.get("bookmarks") or 0),
84
+ likes=int(data.get("likes") or 0),
85
+ )
86
+
87
+ def click(self, username: str, target_url: str) -> ClickResult:
88
+ url = f"{BASE_URL}/api/sys/click"
89
+
90
+ payload = orjson.dumps({"username": username, "url": target_url})
91
+
92
+ resp = self._http.post(
93
+ url,
94
+ headers={**self._headers(username), "Content-Type": "text/plain"},
95
+ content=payload,
96
+ )
97
+
98
+ data = orjson.loads(resp.content)
99
+
100
+ return ClickResult(ok=bool(data.get("ok")))
101
+
102
+ def comments(self, username: str) -> CommentsResult:
103
+ url = f"{BASE_URL}/api/sys/comments"
104
+
105
+ resp = self._http.get(
106
+ url,
107
+ headers=self._headers(username),
108
+ params={"username": username},
109
+ )
110
+
111
+ data = orjson.loads(resp.content)
112
+
113
+ raw_comments = data.get("comments") or []
114
+
115
+ comments = tuple(
116
+ Comment(
117
+ id=str(c.get("id") or ""),
118
+ author_username=str(c.get("author_username") or ""),
119
+ author_avatar_url=str(c.get("author_avatar_url") or ""),
120
+ content=str(c.get("content") or ""),
121
+ created_at=str(c.get("created_at") or ""),
122
+ )
123
+ for c in raw_comments
124
+ )
125
+
126
+ return CommentsResult(
127
+ ok=bool(data.get("ok")),
128
+ comments_enabled=bool(data.get("commentsEnabled")),
129
+ pinned=data.get("pinned"),
130
+ comments=comments,
131
+ )
132
+
133
+ def post_comment(self, username: str, content: str) -> dict[str, Any]:
134
+ url = f"{BASE_URL}/api/sys/comments"
135
+
136
+ resp = self._http.post(
137
+ url,
138
+ headers={**self._headers(username), "Content-Type": "application/json"},
139
+ json_data={"username": username, "content": content},
140
+ )
141
+
142
+ return orjson.loads(resp.content)
143
+
144
+ def audio_proxy(self, audio_url: str) -> bytes:
145
+ url = f"{BASE_URL}/api/sys/audio-proxy"
146
+
147
+ resp = self._http.get(
148
+ url,
149
+ params={"url": audio_url},
150
+ )
151
+
152
+ if resp.status_code != 200:
153
+ raise ValidationError(f"Audio proxy failed: {resp.status_code}")
154
+
155
+ return resp.content
156
+
157
+ def bug_check(self) -> dict[str, Any]:
158
+ url = f"{BASE_URL}/api/sys/bug"
159
+
160
+ resp = self._http.get(url)
161
+
162
+ return orjson.loads(resp.content)
163
+
164
+ def bug_report(
165
+ self,
166
+ title: str,
167
+ description: str,
168
+ steps: str,
169
+ expected: str,
170
+ actual: str,
171
+ browser: str = "",
172
+ device: str = "",
173
+ ) -> dict[str, Any]:
174
+ url = f"{BASE_URL}/api/sys/bug"
175
+
176
+ payload = {
177
+ "title": title,
178
+ "description": description,
179
+ "steps": steps,
180
+ "expected": expected,
181
+ "actual": actual,
182
+ "browser": browser,
183
+ "device": device,
184
+ }
185
+
186
+ resp = self._http.post(
187
+ url,
188
+ headers={"Content-Type": "application/json", "Origin": BASE_URL},
189
+ json_data=payload,
190
+ )
191
+
192
+ return orjson.loads(resp.content)
193
+
194
+ def suggest_check(self) -> dict[str, Any]:
195
+ url = f"{BASE_URL}/api/sys/suggest"
196
+
197
+ resp = self._http.get(url)
198
+
199
+ return orjson.loads(resp.content)
200
+
201
+ def suggest(self, suggestion: str, context: str = "") -> dict[str, Any]:
202
+ url = f"{BASE_URL}/api/sys/suggest"
203
+
204
+ payload = {
205
+ "suggestion": suggestion,
206
+ "context": context,
207
+ }
208
+
209
+ resp = self._http.post(
210
+ url,
211
+ headers={"Content-Type": "application/json", "Origin": BASE_URL},
212
+ json_data=payload,
213
+ )
214
+
215
+ return orjson.loads(resp.content)
jugs/exceptions.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class JugsError(Exception):
5
+ __slots__ = ("message",)
6
+
7
+ def __init__(self, message: str) -> None:
8
+ self.message = message
9
+ super().__init__(message)
10
+
11
+
12
+ class AuthError(JugsError):
13
+ pass
14
+
15
+
16
+ class RateLimitError(JugsError):
17
+ pass
18
+
19
+
20
+ class NotFoundError(JugsError):
21
+ pass
22
+
23
+
24
+ class ValidationError(JugsError):
25
+ pass
26
+
27
+
28
+ class ProxyError(JugsError):
29
+ pass
jugs/http.py ADDED
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import random
5
+ from typing import Any, Iterator, Mapping, Sequence
6
+
7
+ import httpx
8
+ import orjson
9
+
10
+ from jugs.constants import BASE_URL, DEFAULT_UA
11
+ from jugs.exceptions import ProxyError
12
+
13
+
14
+ class ProxyRotator:
15
+ __slots__ = ("_proxies", "_cycle", "_lock_idx")
16
+
17
+ def __init__(self, proxies: Sequence[str] | None = None) -> None:
18
+ self._proxies: list[str] = list(proxies) if proxies else []
19
+ self._cycle: Iterator[str] | None = None
20
+ self._lock_idx: int = 0
21
+
22
+ if self._proxies:
23
+ self._cycle = itertools.cycle(self._proxies)
24
+
25
+ def next(self) -> str | None:
26
+ if not self._cycle:
27
+ return None
28
+
29
+ return next(self._cycle)
30
+
31
+ def random(self) -> str | None:
32
+ if not self._proxies:
33
+ return None
34
+
35
+ return random.choice(self._proxies)
36
+
37
+ def __len__(self) -> int:
38
+ return len(self._proxies)
39
+
40
+ def add(self, proxy: str) -> None:
41
+ self._proxies.append(proxy)
42
+ self._cycle = itertools.cycle(self._proxies)
43
+
44
+
45
+ class HTTPSession:
46
+ __slots__ = (
47
+ "_client",
48
+ "_rotator",
49
+ "_base_headers",
50
+ "_timeout",
51
+ )
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ proxies: Sequence[str] | None = None,
57
+ timeout: float = 10.0,
58
+ http2: bool = False,
59
+ follow_redirects: bool = True,
60
+ ) -> None:
61
+ self._rotator = ProxyRotator(proxies)
62
+ self._timeout = timeout
63
+ self._base_headers: dict[str, str] = {
64
+ "User-Agent": DEFAULT_UA,
65
+ "Accept": "*/*",
66
+ "Accept-Language": "en-US,en;q=0.9",
67
+ }
68
+
69
+ proxy = self._rotator.next()
70
+
71
+ self._client = httpx.Client(
72
+ http2=http2,
73
+ timeout=timeout,
74
+ follow_redirects=follow_redirects,
75
+ proxy=proxy,
76
+ headers=self._base_headers,
77
+ )
78
+
79
+ def _rotate(self) -> None:
80
+ proxy = self._rotator.next()
81
+
82
+ if proxy is None:
83
+ return
84
+
85
+ cookies = self._client.cookies
86
+ self._client.close()
87
+
88
+ self._client = httpx.Client(
89
+ http2=False,
90
+ timeout=self._timeout,
91
+ follow_redirects=True,
92
+ proxy=proxy,
93
+ headers=self._base_headers,
94
+ cookies=cookies,
95
+ )
96
+
97
+ def request(
98
+ self,
99
+ method: str,
100
+ url: str,
101
+ *,
102
+ headers: Mapping[str, str] | None = None,
103
+ content: bytes | None = None,
104
+ json_data: Any = None,
105
+ params: Mapping[str, str] | None = None,
106
+ rotate: bool = False,
107
+ ) -> httpx.Response:
108
+ if rotate and self._rotator:
109
+ self._rotate()
110
+
111
+ req_headers = dict(self._base_headers)
112
+
113
+ if headers:
114
+ req_headers.update(headers)
115
+
116
+ body: bytes | None = content
117
+
118
+ if json_data is not None:
119
+ body = orjson.dumps(json_data)
120
+ req_headers.setdefault("Content-Type", "application/json")
121
+
122
+ try:
123
+ return self._client.request(
124
+ method,
125
+ url,
126
+ headers=req_headers,
127
+ content=body,
128
+ params=params,
129
+ )
130
+ except httpx.ProxyError as exc:
131
+ raise ProxyError(str(exc)) from exc
132
+
133
+ def get(
134
+ self,
135
+ url: str,
136
+ *,
137
+ headers: Mapping[str, str] | None = None,
138
+ params: Mapping[str, str] | None = None,
139
+ rotate: bool = False,
140
+ ) -> httpx.Response:
141
+ return self.request("GET", url, headers=headers, params=params, rotate=rotate)
142
+
143
+ def post(
144
+ self,
145
+ url: str,
146
+ *,
147
+ headers: Mapping[str, str] | None = None,
148
+ content: bytes | None = None,
149
+ json_data: Any = None,
150
+ params: Mapping[str, str] | None = None,
151
+ rotate: bool = False,
152
+ ) -> httpx.Response:
153
+ return self.request(
154
+ "POST",
155
+ url,
156
+ headers=headers,
157
+ content=content,
158
+ json_data=json_data,
159
+ params=params,
160
+ rotate=rotate,
161
+ )
162
+
163
+ def set_cookie(self, name: str, value: str, domain: str = "www.jugs.lol") -> None:
164
+ self._client.cookies.set(name, value, domain=domain)
165
+
166
+ def get_cookies(self) -> httpx.Cookies:
167
+ return self._client.cookies
168
+
169
+ def clear_cookies(self) -> None:
170
+ self._client.cookies.clear()
171
+
172
+ def close(self) -> None:
173
+ self._client.close()
174
+
175
+ def __enter__(self) -> HTTPSession:
176
+ return self
177
+
178
+ def __exit__(self, *args: object) -> None:
179
+ self.close()
jugs/models.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True, frozen=True)
8
+ class ViewResult:
9
+ ok: bool
10
+ counted: bool
11
+
12
+
13
+ @dataclass(slots=True, frozen=True)
14
+ class StatusResult:
15
+ ok: bool
16
+ bookmarked: bool
17
+ liked: bool
18
+ bookmarks: int
19
+ likes: int
20
+
21
+
22
+ @dataclass(slots=True, frozen=True)
23
+ class Comment:
24
+ id: str
25
+ author_username: str
26
+ author_avatar_url: str
27
+ content: str
28
+ created_at: str
29
+
30
+
31
+ @dataclass(slots=True, frozen=True)
32
+ class CommentsResult:
33
+ ok: bool
34
+ comments_enabled: bool
35
+ pinned: Any
36
+ comments: tuple[Comment, ...]
37
+
38
+
39
+ @dataclass(slots=True, frozen=True)
40
+ class DiscordUser:
41
+ id: str
42
+ username: str
43
+ global_name: str | None
44
+ avatar_url: str | None
45
+ avatar_decoration_url: str | None
46
+
47
+
48
+ @dataclass(slots=True, frozen=True)
49
+ class DiscordMember:
50
+ display_name: str | None
51
+ guild_avatar_url: str | None
52
+ server_booster: bool
53
+ roles: tuple[str, ...]
54
+
55
+
56
+ @dataclass(slots=True, frozen=True)
57
+ class PresenceResult:
58
+ ok: bool
59
+ joined: bool
60
+ status: str
61
+ activities: tuple[dict[str, Any], ...]
62
+ user: DiscordUser | None
63
+ member: DiscordMember | None
64
+
65
+
66
+ @dataclass(slots=True, frozen=True)
67
+ class ClickResult:
68
+ ok: bool
69
+
70
+
71
+ @dataclass(slots=True, frozen=True)
72
+ class ActionResult:
73
+ raw: str
74
+ data: Any
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.5
2
+ Name: jugs
3
+ Version: 1.0.0
4
+ Summary: High-performance Python client for jugs.lol
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx[http2]>=0.27.0
7
+ Requires-Dist: orjson>=3.10.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest; extra == 'dev'
10
+ Requires-Dist: pytest-asyncio; extra == 'dev'
@@ -0,0 +1,14 @@
1
+ jugs/__init__.py,sha256=vrQQezHzTDJf6DWFNGRtFOllHCEezj-4LCNe3r1S1WI,745
2
+ jugs/auth.py,sha256=q0_2voJlVtCrUkn0lgE6HuTkcZBOWRax_rqGnfzGOuk,4636
3
+ jugs/client.py,sha256=cx6vTisZRHiWj1bpg0lBJn3SglDKFnyCqOu1U_rNwVc,4839
4
+ jugs/constants.py,sha256=2p08gIK6dRvIA1dhygOW3cSjWSzGnj8iEV9m1Vnj_xE,1566
5
+ jugs/exceptions.py,sha256=k5VeincwB2rZivCqdKaqPHTzKYh94PQfIAqQwkBZ0vA,417
6
+ jugs/http.py,sha256=Ins0LoSfwm3j-7FDz2IzLy-GbETUKg79WsqQOUgHxMU,4580
7
+ jugs/models.py,sha256=CE_p9vRBB9WZHRopAbGX-9QSKUfl--DySO3xHNlMvw0,1345
8
+ jugs/endpoints/__init__.py,sha256=REU8O3wbb4X-9OuLZA86tDuJyfXa0J9m5-tvH6G4_Lg,216
9
+ jugs/endpoints/actions.py,sha256=qcl0tqU81YNTJa5caoLMSJoXq7nF5rCPWJIMJyy77dw,2238
10
+ jugs/endpoints/discord.py,sha256=yj94-ZomI0auvntS09s5Tna-CQi2mpJurPFvSzixdGc,1811
11
+ jugs/endpoints/sys.py,sha256=Oek0WVUm_tSnqgwsog3Janw4f4XSAn8iLLAK9XKrrZk,5844
12
+ jugs-1.0.0.dist-info/METADATA,sha256=OHQ74R72k-vcxCKOiWSWvkRYHYlDOPae434qu40NQwI,295
13
+ jugs-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ jugs-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any